From 8b8ce39ee1e47dee3e48ca1fa3f1aa4d3a01c1f5 Mon Sep 17 00:00:00 2001 From: ppmarkek <153396079+ppmarkek@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:50:10 +0300 Subject: [PATCH 1/5] Improve AnchorLoop workflow and interface polish --- .../skills/anchorloop/.anchorloop-skill.json | 19 - .codex/skills/anchorloop/SKILL.md | 107 ------ .../skills/anchorloop/references/workflow.md | 202 ---------- .github/workflows/ci.yml | 15 + .github/workflows/release.yml | 42 ++- .gitignore | 3 + CHANGELOG.md | 56 +++ CONTRIBUTING.md | 33 +- README.md | 75 +++- docs/ANCHOR_DECISION_MAP.md | 25 +- docs/MIGRATION_0.2.md | 88 +++++ docs/PORTABLE_SKILL.md | 59 ++- docs/PROJECT_PLAN.md | 39 +- docs/i18n/README.de.md | 5 + docs/i18n/README.es.md | 5 + docs/i18n/README.fr.md | 5 + docs/i18n/README.ja.md | 5 + docs/i18n/README.pt-BR.md | 5 + docs/i18n/README.ru.md | 8 +- docs/i18n/README.zh-CN.md | 5 + npm/scripts/registry-smoke.js | 8 + npm/scripts/version.js | 34 +- npm/test/anchorloop.test.js | 70 ++++ npm/test/version.test.js | 100 ++++- package-lock.json | 19 +- package.json | 6 +- src/anchorloop/cli.py | 26 ++ src/anchorloop/project.py | 103 +++++- src/anchorloop/quality.py | 57 ++- src/anchorloop/skill_install.py | 344 +++++++++++++++--- src/anchorloop/version.py | 2 +- tests/test_actual_diff_risk.py | 342 +++++++++++++++++ tests/test_filesystem_safety.py | 57 +++ tests/test_skill_install_matrix.py | 209 +++++++++++ tests/test_skill_install_recovery.py | 318 +++++++++++++++- 35 files changed, 1977 insertions(+), 519 deletions(-) delete mode 100644 .codex/skills/anchorloop/.anchorloop-skill.json delete mode 100644 .codex/skills/anchorloop/SKILL.md delete mode 100644 .codex/skills/anchorloop/references/workflow.md create mode 100644 CHANGELOG.md create mode 100644 docs/MIGRATION_0.2.md create mode 100644 tests/test_actual_diff_risk.py create mode 100644 tests/test_skill_install_matrix.py diff --git a/.codex/skills/anchorloop/.anchorloop-skill.json b/.codex/skills/anchorloop/.anchorloop-skill.json deleted file mode 100644 index 0412f6c..0000000 --- a/.codex/skills/anchorloop/.anchorloop-skill.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "files": [ - { - "path": "SKILL.md", - "sha256": "sha256:9c12146867c019e159312cc0f9c4b0279c08b0517a4235c081af0b19aed8f6a2" - }, - { - "path": "references/workflow.md", - "sha256": "sha256:8dccdb9e4bf2b1b5896424938e86d5943219aec85727210d1fb3598b306a9ad4" - } - ], - "npx_package": "anchorloop@0.1.0", - "platform": "codex", - "runtime": "npx", - "schema_version": 2, - "scope": "project", - "skill": "anchorloop", - "version": "0.1.0" -} diff --git a/.codex/skills/anchorloop/SKILL.md b/.codex/skills/anchorloop/SKILL.md deleted file mode 100644 index 2f2018b..0000000 --- a/.codex/skills/anchorloop/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: anchorloop -description: Guides agents through AnchorLoop's engineer-controlled, local workflow without owning its state. Use when a repository contains .anchor/, the user asks to follow AnchorLoop, or an AI-assisted coding task needs recorded briefs, approvals, rules, quality evidence, or verification. ---- - -# AnchorLoop - -AnchorLoop is agent-neutral. This skill is only a thin adapter: the local -command runner and the project's `.anchor/` directory are the source of truth. - -The installer supplies `npx --yes anchorloop@0.1.0` as the command runner. Use that -exact prefix for every AnchorLoop command below. Do not replace a pinned npx -runner with `@latest`, add a project-local npm cache, or install packages into -the repository. - -## First action - -1. Run `npx --yes anchorloop@0.1.0 status` from the project root. -2. Read `.anchor/next-action.md` when it exists. -3. Follow only the action allowed by the recorded task state. - -If AnchorLoop is not configured, explain that `npx --yes anchorloop@0.1.0 add --apply` will create -project-local state. Do not create it unless the engineer has asked for setup. - -## Engineer-owned gates - -Do not impersonate an engineer or claim that a human has approved work. - -- Do not run `npx --yes anchorloop@0.1.0 approve`, `npx --yes anchorloop@0.1.0 rules approve`, - `npx --yes anchorloop@0.1.0 rules supersede`, `npx --yes anchorloop@0.1.0 verify`, or - `npx --yes anchorloop@0.1.0 close`, or `npx --yes anchorloop@0.1.0 outcome` unless the engineer explicitly asks for - that exact recorded action. -- `--by` is an audit attribution, not proof of identity. Preserve the supplied - provenance and do not invent it. -- Verification values for `--result`, `--reason`, and `--recall` must reflect - the engineer's observed outcome and explanation; do not infer or prefill them. -- Do not edit `.anchor/` JSON files by hand to bypass a transition or rule. - -## Normal coding flow - -Use the CLI to record the real workflow. Let `AUTO` select the minimum risk -mode. For a normal `STANDARD` task, obtain the engineer's actual plan inputs; -never invent the human artifact or comprehension statement: - -~~~text -npx --yes anchorloop@0.1.0 start "short task title" -npx --yes anchorloop@0.1.0 brief --by "Engineer name" --outcome "..." --scope "..." --constraints "..." --invariant "..." --uncertainty "..." -npx --yes anchorloop@0.1.0 plan --summary "..." --mode AUTO --task-type "..." --approach "..." --alternative "..." --risk "..." --verification "..." --human-artifact "..." --comprehension "..." --by "Engineer name" -npx --yes anchorloop@0.1.0 approve --by "Engineer name" -npx --yes anchorloop@0.1.0 implement -npx --yes anchorloop@0.1.0 review -npx --yes anchorloop@0.1.0 precommit -npx --yes anchorloop@0.1.0 verify --by "Engineer name" --result pass --reason "..." --recall "..." -npx --yes anchorloop@0.1.0 close -~~~ - -`AUTO` recommends `FAST` only for low-risk documentation/chore work, -`STANDARD` for ordinary changes, and `CAREFUL` for sensitive work such as -authentication, payments, secrets, migrations, concurrency, infrastructure, -destructive changes, public APIs, or new dependencies. `CAREFUL` also requires -`--rollback-mitigation`. An explicit downgrade requires -`--mode-override-reason`; surface that decision instead of hiding it. - -The human artifact is a real engineer-created acceptance case, prediction, -decision note, or similar piece of reasoning. `--comprehension` and verify's -`--recall` are the engineer's own explanations. Ask for missing input and keep -the gate pending; do not generate answers on the engineer's behalf. - -If verification fails, preserve the failure and use `npx --yes anchorloop@0.1.0 revise` to return -to implementation or planning. If code changes after `npx --yes anchorloop@0.1.0 precommit`, rerun -review and the quality gate before verification. - -After a `CAREFUL` task closes, AnchorLoop schedules recall **24 hours after -that close time**. Run `npx --yes anchorloop@0.1.0 status` and inspect -`pending_recalls` (or the closed task's `recall_due_at`). When that time has -passed and the engineer explicitly supplies a response, record delayed recall -with `npx --yes anchorloop@0.1.0 recall --task --by "Engineer name" --response -"..." --score <0-5>`. Never backdate, fabricate, or auto-complete recall. - -Only when the engineer supplies an observed follow-up, record defects, -rollback, corrective refactor, and notes with `npx --yes anchorloop@0.1.0 outcome`. -`npx --yes anchorloop@0.1.0 report --format json|csv` is read-only and aggregates local -closed-task pilot fields; treat reported time/token/model/outcome values as -audit data, not trusted provider telemetry. - -## Rules and evidence - -- Treat rules as inactive until the engineer records `npx --yes anchorloop@0.1.0 rules approve`. -- Never replace an active rule silently; use the explicit supersede command - only with an engineer-provided reason. -- Keep the patch within the recorded brief and surface a changed scope before - editing code. -- Use `npx --yes anchorloop@0.1.0 precommit` before asking the engineer to verify behaviour. -- Treat generated cache as local-only: never stage or commit it. Before a - cache-producing tool runs, verify that its exact output path is ignored by - Git; follow the cache policy in the workflow reference. -- Treat `.anchor/project.lock`, `.anchor/transactions/`, and - `.anchor/outbox/` as runtime recovery artifacts. They must stay ignored and - must not be staged or copied into reports. - -## Optional integrations - -Graphify is separate and opt-in. Do not install it, create a graph, or change -host configuration without explicit engineer approval. - -For command details and recovery guidance, see -[the workflow reference](references/workflow.md). diff --git a/.codex/skills/anchorloop/references/workflow.md b/.codex/skills/anchorloop/references/workflow.md deleted file mode 100644 index 7458e86..0000000 --- a/.codex/skills/anchorloop/references/workflow.md +++ /dev/null @@ -1,202 +0,0 @@ -# AnchorLoop workflow reference - -## Install this adapter - -The Python package keeps the standalone CLI. Install its portable skill adapter -only when a project or user wants agent discovery. The installer supplies -`npx --yes anchorloop@0.1.0` as the command runner in the installed skill: - -~~~powershell -# Cross-framework project skill (recommended) -npx --yes anchorloop@0.1.0 install --project --platform agents --apply - -# Optional Codex-specific project location -npx --yes anchorloop@0.1.0 install --project --platform codex --apply -~~~ - -The project option writes only: - -~~~text -.agents/skills/anchorloop/ # platform=agents -.codex/skills/anchorloop/ # platform=codex -~~~ - -The installer copies packaged Markdown and an ownership marker. It does not -alter `.anchor/`, application code, `AGENTS.md`, hooks, or Graphify settings. -Use `npx --yes anchorloop@0.1.0 uninstall --project --platform agents --apply` to remove only -unchanged installer-owned files. If a packaged asset was edited locally, the -installer stops and requires an explicit `--force` before replacing or removing -it. - -## Local cache - -Treat generated cache as local-only; never stage it, commit it, or bypass an -ignore rule with `git add -f`. Before enabling a cache-producing tool, verify -that its precise output path is ignored by the project's root `.gitignore`. -AnchorLoop projects must keep these paths ignored: - -~~~text -/cache/ -/.cache/ -/.anchor/cache/ -/.npm/ -/.npm-cache/ -graphify-out/ -__pycache__/ -*.py[cod] -~~~ - -Keep `.anchor/.gitignore` entries for `cache/`, `logs/`, -`graphify/query-history.jsonl`, `project.lock`, `transactions/`, and `outbox/`. -The last three are lock/recovery internals, not project evidence. After -changing an ignore rule, verify it with -`git check-ignore -v --no-index `. - -After upgrading an existing AnchorLoop project, an engineer may rerun -`npx --yes anchorloop@0.1.0 add --apply` to append missing managed ignore entries -without replacing custom lines. An ignore rule does not untrack a path that is -already in Git; inspect the index and remove runtime artifacts from it through -the project's normal review process. - -## State progression - -~~~text -briefing -> planned -> approved -> implementing -> review_ready --> quality_checked -> verified -> closed -~~~ - -The engineer brief, recorded approval, rule snapshot, quality evidence, and -verification stay in `.anchor/tasks/active.json`. Do not rewrite that file -manually. AnchorLoop hashes the brief, plan, and pinned ruleset at approval. -If any of them changes afterward, it archives the stale approval and returns -the task to briefing (brief change) or planned (plan/ruleset change) so the -engineer can record a fresh approval. - -## Human ownership modes - -Use `--mode AUTO` unless the engineer deliberately selects a mode. AUTO -recommends FAST for low-risk documentation/chore work, STANDARD for ordinary -changes, and CAREFUL for sensitive changes such as auth, payments, secrets, -migrations, concurrency, infrastructure, destructive operations, public APIs, -or new dependencies. A downgrade requires a recorded -`--mode-override-reason`. - -STANDARD and CAREFUL plans require explicit engineer-owned reasoning: - -~~~powershell -npx --yes anchorloop@0.1.0 plan ` - --summary "Bounded implementation summary" ` - --mode AUTO ` - --task-type "feature" ` - --approach "Chosen approach" ` - --alternative "Rejected alternative and why" ` - --risk "Primary failure mode" ` - --verification "How the invariant will be checked" ` - --human-artifact "Actual engineer-created acceptance case or decision note" ` - --comprehension "Engineer's prediction or explanation" ` - --by "Engineer name" -~~~ - -CAREFUL additionally requires `--rollback-mitigation`. Do not let an agent -invent the human artifact, comprehension statement, or rollback decision. If -those inputs do not exist, the plan gate is intentionally incomplete. - -STANDARD and CAREFUL verification includes the engineer's immediate recall: - -~~~powershell -npx --yes anchorloop@0.1.0 verify --by "Engineer name" --result pass --reason "Observed result" --recall "Why the approach worked and what would falsify it" -~~~ - -When known, append reported experiment metadata with `--agent-turns`, -`--input-tokens`, `--output-tokens`, `--active-minutes`, `--agent-provider`, -and `--agent-model`. Provider and model must be supplied together. These are -auditable reported values, not trusted usage telemetry. - -A closed CAREFUL task schedules delayed recall **24 hours after close**. After -its immutable `recall_due_at` has passed, record the engineer's response and -0-5 score: - -~~~powershell -npx --yes anchorloop@0.1.0 recall --task --by "Engineer name" --response "Recalled trade-off and invariant" --score 4 -~~~ - -## Post-completion outcome and pilot report - -When the engineer actually observes a post-completion result, record the -latest cumulative defect count and whether rollback or a corrective refactor -was needed: - -~~~powershell -npx --yes anchorloop@0.1.0 outcome --task --by "Engineer name" --defects 1 --rollback no --corrective-refactor yes --notes "Observed follow-up" -~~~ - -Do not infer these fields from code or issue text. The local aggregate is -read-only and can be emitted as JSON or CSV: - -~~~powershell -npx --yes anchorloop@0.1.0 report --format json -npx --yes anchorloop@0.1.0 report --format csv -~~~ - -Reported token, time, provider/model, recall, and outcome values are audit -inputs, not provider-verified telemetry. - -## Quality evidence - -`npx --yes anchorloop@0.1.0 precommit` records deterministic checks and a workspace fingerprint. -If the fingerprint differs before verification or close, AnchorLoop resets the -task to `review_ready`; review the code and run -`npx --yes anchorloop@0.1.0 precommit` again. - -## Failed verification - -Record the result first: - -~~~powershell -npx --yes anchorloop@0.1.0 verify --by "Engineer name" --result fail --reason "Observed issue" --recall "Why the expected behavior did not hold" -~~~ - -Then choose the narrowest valid revision: - -~~~powershell -# The original approved scope is still valid -npx --yes anchorloop@0.1.0 revise --target implement --reason "Fix the observed behaviour" - -# The plan or scope decision changed -npx --yes anchorloop@0.1.0 revise --target plan --reason "Choose a different approach" -npx --yes anchorloop@0.1.0 plan --summary "Revised approach" --mode AUTO --task-type "feature" --approach "New approach" --alternative "Rejected alternative" --risk "Primary risk" --verification "Acceptance check" --human-artifact "Engineer's artifact" --comprehension "Engineer's prediction" --by "Engineer name" -npx --yes anchorloop@0.1.0 approve --by "Engineer name" -~~~ - -## Recovery and diagnosis - -All mutating workflow commands are serialized by a project lock and commit -state plus ordered events through a durable redo journal. Read commands refuse -to present a partial transaction. `doctor` is inspect-only by default: - -~~~powershell -npx --yes anchorloop@0.1.0 doctor -npx --yes anchorloop@0.1.0 doctor --strict -npx --yes anchorloop@0.1.0 doctor --repair -~~~ - -Use `--repair` only when the engineer asks to complete an interrupted -transaction or repair a torn final event-log line. It never bypasses an -approval or fabricates workflow evidence. - -## Rule migration - -Approved rule documents created by current AnchorLoop versions carry an -approval-time digest. If an older active rule lacks that digest, it cannot -govern a new task. Propose and approve a replacement in the same category, -then migrate it explicitly: - -~~~powershell -npx --yes anchorloop@0.1.0 rules supersede --by "Engineer name" --reason "Migration reason" -~~~ - -## Trust boundary - -Without a trusted host adapter or separate approval channel, AnchorLoop is an -auditable workflow guardrail, not an access-control boundary. A terminal-capable -agent can invoke commands, so agents must not treat `--by` as authentication. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1d9914..c0d6c28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,8 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.11" + - run: npm ci --ignore-scripts + - run: npm run version:check - run: npm test - run: npm run pack:check - name: Smoke-test the packed npx launcher @@ -157,6 +159,7 @@ jobs: Push-Location -LiteralPath $project try { Invoke-PackedAnchor add --apply + Invoke-PackedAnchor doctor --strict foreach ($ignored in @( "cache/example", ".cache/example", @@ -213,7 +216,9 @@ jobs: --corrective-refactor no ` --notes "No post-completion issue in the packed smoke window." Invoke-PackedAnchor report --format json + Invoke-PackedAnchor doctor --strict Invoke-PackedAnchor uninstall + Invoke-PackedAnchor doctor --strict } finally { Pop-Location } @@ -226,6 +231,16 @@ jobs: throw "Packed lifecycle leaked $forbidden into the project." } } + foreach ($relative in @(".anchor/transactions/pending", ".anchor/outbox")) { + $directory = Join-Path $project $relative + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "Packed lifecycle did not retain the expected $relative directory." + } + $residue = @(Get-ChildItem -LiteralPath $directory -Force -File) + if ($residue.Count -ne 0) { + throw "Packed lifecycle retained recovery residue in $relative." + } + } } finally { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9d8127..cd09fc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,9 @@ jobs: persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: "22" + node-version: "22.14.0" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.11" @@ -46,6 +48,9 @@ jobs: npm install --global npm@11.18.0 test "$(npm --version)" = "11.18.0" + - name: Install the locked release dependencies + run: npm ci --ignore-scripts + - name: Verify canonical version and exact release tag id: release shell: bash @@ -59,6 +64,19 @@ jobs: printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" printf 'package_name=%s\n' "$package_name" >> "$GITHUB_OUTPUT" + - name: Refuse an already published version + shell: bash + env: + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + if npm view "${PACKAGE_NAME}@${VERSION}" version --json >/dev/null 2>&1; then + echo "${PACKAGE_NAME}@${VERSION} is already published and cannot be overwritten." >&2 + exit 1 + fi + - name: Require a GitHub-verified signed annotated tag shell: bash env: @@ -91,15 +109,15 @@ jobs: exit 1 } - - name: Require the npm bootstrap reservation + - name: Require the existing npm package identity shell: bash env: PACKAGE_NAME: ${{ steps.release.outputs.package_name }} run: | set -euo pipefail if ! published_name="$(npm view "${PACKAGE_NAME}" name --json 2>/dev/null)"; then - echo "${PACKAGE_NAME} does not exist in npm yet." >&2 - echo "Reserve it first with a lower, version-consistent bootstrap release; then configure the trusted publisher." >&2 + echo "Could not verify the existing npm package ${PACKAGE_NAME}." >&2 + echo "Restore registry access and confirm the published 0.1.0 baseline before releasing." >&2 exit 1 fi test "$(jq -r '.' <<< "$published_name")" = "$PACKAGE_NAME" || { @@ -132,3 +150,19 @@ jobs: --package "${{ steps.release.outputs.package_name }}" --version "${{ steps.release.outputs.version }}" + - name: Verify published commit identity + shell: bash + env: + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + published_head="$(npm view "${PACKAGE_NAME}@${VERSION}" gitHead)" + expected_head="$(git rev-parse HEAD)" + + test "$published_head" = "$expected_head" || { + echo "Published gitHead ${published_head} does not match ${expected_head}." >&2 + exit 1 + } + diff --git a/.gitignore b/.gitignore index fa196b0..31bd2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ npm-debug.log* /.anchor/cache/ /.npm/ /.npm-cache/ + +# Local AnchorLoop dogfooding installation +/.codex/skills/anchorloop/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c1cd08 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable AnchorLoop changes are documented here. npm releases are immutable; +an entry marked **Unreleased** describes repository state, not production npm +availability. + +## 0.2.0 - Unreleased + +Published production remains `anchorloop@0.1.0` until the signed `v0.2.0` tag +passes the complete release workflow. + +### Added + +- compare-and-apply recovery tied to exact before-state content, size, mode, + and digest; +- cross-platform project locking, durable transaction journals, ordered event + outbox delivery, bounded receipts, and explicit recovery diagnostics; +- core Python validation for task, ownership, verification, metrics, recall, + and closed-task integrity; +- actual-diff risk escalation before review and precommit, with explicit + audited overrides for reviewed CAREFUL paths; +- FAST, STANDARD, and CAREFUL ownership modes with close-relative delayed + recall and explicit approval evidence; +- project/global portable skill installation for Agent Skills, Codex, Cursor, + Gemini CLI, Claude Code, and OpenCode; +- packed-artifact and registry-backed lifecycle smoke checks; +- migration guidance from `0.1.0` and this changelog. + +### Changed + +- recovery completes an interrupted project or skill transaction and stops the + current call without automatically running the newly requested mutation; +- release publication is OIDC-only and requires a verified signed annotated + tag, green exact-tag CI, an unpublished exact npm version, and matching npm + `gitHead`; +- generated local skill installations are ignored and are no longer repository + source of truth; +- the npm package no longer depends recursively on `anchorloop@0.1.0`. + +### Compatibility + +- `.anchor/` remains the project workflow source of truth and must not be + deleted during upgrade; +- `add --apply` refreshes managed support files while preserving existing task, + rule, approval, and audit records; +- locally modified installed skill files are not overwritten unless the user + reviews the diff and explicitly chooses `--force`; +- legacy rules without approval-time integrity evidence require explicit + supersession rather than silent activation. + +See [the 0.1.0 to 0.2.0 migration guide](docs/MIGRATION_0.2.md). + +## 0.1.0 - Published + +- Initial public npm production release of the local, agent-neutral AnchorLoop + workflow and pinned skill adapter. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 871377e..611fe24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,9 @@ # Contributing to AnchorLoop -AnchorLoop 0.1 is a public-alpha release candidate. Small, testable changes that strengthen its local, -agent-neutral workflow core are preferred over broad integrations. +`anchorloop@0.1.0` is the published public-alpha baseline; current `main` is +the unreleased `0.2.0` release candidate. Small, testable changes that +strengthen its local, agent-neutral workflow core are preferred over broad +integrations. ## Local development @@ -32,8 +34,10 @@ in the normal suite; do not replace them with Linux-only mocks. ## npm launcher -The optional `npx anchorloop install` route requires Node.js 18 or newer and -must remain a thin launcher around the Python core. When changing its package, +The optional exact-version npm route (for example, +`npx --yes anchorloop@0.2.0 install` after publication) requires Node.js 18 or +newer and must remain a thin launcher around the Python core. During candidate +development exercise the checkout directly. When changing its package, launcher, or skill templates: ~~~powershell @@ -62,7 +66,7 @@ protected `npm-release` GitHub environment. Keep the CI jobs as required checks on `main`. Do not add a long-lived npm token to the workflow. Create a signed annotated tag whose name exactly matches the canonical version -(for example, `git tag -s v0.1.0 -m "AnchorLoop v0.1.0"`) and ensure the +(for example, `git tag -s v0.2.0 -m "AnchorLoop 0.2.0"`) and ensure the signing key is associated with the maintainer's GitHub account before pushing the tag. The release workflow reruns the full Python, wheel, npm, OS, and Node 18/20/22 matrix for the tagged commit; checks the GitHub-verified tag @@ -75,17 +79,14 @@ Hosted runner images and Python build tooling still receive upstream updates; do not claim binary reproducibility without a separately pinned toolchain and runner-image policy. -The package name must exist before npm trusted publishing can be configured. -For the first release only, reserve `anchorloop` with a lower, fully -version-consistent bootstrap release such as `0.0.0` from a clean disposable -checkout (or switch every manifest, runner, and document to an owned scope). -Do **not** manually publish the intended alpha version: npm versions are -immutable, so that would prevent the signed-tag workflow from publishing the -same version with OIDC provenance. After the reservation, configure the -repository/workflow/environment trusted publisher and publish `0.1.0` through -the signed tag. Deprecate the bootstrap version after the registry smoke passes. -Do not weaken `release.yml` or add a long-lived token merely to hide a missing -bootstrap step. +`anchorloop@0.1.0` is the published production baseline. npm versions are +immutable: before creating `v0.2.0`, verify that `anchorloop@0.2.0` does not +exist. The release workflow repeats that check, refuses an existing exact +version, and verifies the published `gitHead` after the registry smoke. Never +publish `0.2.0` manually, move a published tag, or attempt to overwrite a +failed release. Fix pre-publish failures in a new commit; for a post-publish +defect, deprecate the affected version and release `0.2.1`. Do not weaken +`release.yml` or add a long-lived token. ## Pull requests diff --git a/README.md b/README.md index ac39451..82742a8 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,15 @@ AnchorLoop lets an AI agent implement code without taking ownership away from th ## Status -AnchorLoop 0.1 is a release candidate for its first public alpha. The local -Python CLI, installable agent-neutral skill adapter, transaction/recovery -boundary, human-ownership modes, and npm launcher are implemented and locally -packaged. The public npm name still requires a one-time bootstrap publish, so -the one-command registry install is not production-available until a lower -bootstrap version reserves the name and the tagged `0.1.0` release plus -post-publish smoke test succeed. +**Published production:** `anchorloop@0.1.0` + +**Unreleased main:** `0.2.0` release candidate + +The published `0.1.0` package remains the production version. Current `main` +contains the unreleased recovery, validation, ownership, release-safety, and +multi-agent installer work planned for `0.2.0`. Until the signed `v0.2.0` tag +passes the complete release workflow, do not describe those additions as +available from npm `latest`. ## Core idea @@ -75,9 +77,12 @@ The source of truth is the local `anchor` CLI and the project’s `.anchor/` dir Every host gets the same task states and approval rules. Native integrations must remain thin adapters; they never own the workflow state. -## What works now +## What is implemented in the unreleased 0.2.0 candidate -- `anchor install` and `anchor uninstall` preview then manage a packaged, project- or user-scoped skill adapter for generic Agent Skills or explicit Codex locations. They never modify the `.anchor/` workflow state. +- `anchor install` and `anchor uninstall` preview then manage a packaged, + project- or user-scoped skill adapter for Agent Skills, Codex, Cursor, + Gemini CLI, Claude Code, and OpenCode destinations. They never modify the + `.anchor/` workflow state. - Anchor-managed state and skill destinations reject symlink and Windows reparse-point components. Writes use unique temporary files plus atomic replacement; `--force` never bypasses that boundary. - Project mutations are serialized by a cross-platform lock. State and ordered events commit through a durable redo journal, interrupted commands recover @@ -90,6 +95,9 @@ Every host gets the same task states and approval rules. Native integrations mus transaction or torn final event-log entry. - Failed verification is preserved and can explicitly return to implementation or planning with `anchor revise`; it no longer strands the active task. - The quality gate records a deterministic workspace fingerprint. Verification and close are blocked when the checked code changes afterward. +- Before `review_ready` and again before `precommit`, AnchorLoop evaluates the + actual Git diff. CAREFUL paths block a lower-mode task until the engineer + revises the plan or records a path-specific reviewed override. - `anchor init` and `anchor add` preview project setup and require `--apply` before creating files. - Setup creates portable `.anchor/` state, baseline **proposed** rules, Graphify integration metadata, a portable agent protocol, and a generated next-action file. - The task flow enforces `start → brief → plan → approve → implement → review → precommit → verify → close`. @@ -100,17 +108,30 @@ Every host gets the same task states and approval rules. Native integrations mus Graphify installation, full language-specific security tooling, project-specific test commands, external research, skill discovery, and native host adapters are planned next. AnchorLoop never installs them silently. -## Install as a skill — guided setup +## Install the published 0.1.0 package Requirements: Node.js 18 or newer and Python 3.11 or newer. -From any project, run: +Use the exact published production version: ~~~sh -npx anchorloop install +npx --yes anchorloop@0.1.0 install --project --platform codex --apply ~~~ -In a terminal, a compact setup wizard asks where AnchorLoop should live: +Do not use an unversioned `npx anchorloop install` command to test the features +documented below: npm `latest` still resolves to `0.1.0` until the `0.2.0` +release completes. + +## Unreleased 0.2.0 guided setup + +From a development checkout, install the current Python CLI in editable mode: + +~~~sh +python -m pip install -e . +anchor install --interactive +~~~ + +The compact setup wizard asks where AnchorLoop should live: - **This project** installs the portable Agent Skills standard at `.agents/skills/anchorloop/` for compatible agents in the repository. @@ -124,13 +145,19 @@ It installs only the thin skill adapter with a pinned `npx --yes anchorloop@` runner. It never creates `.anchor/`, modifies application code, adds `node_modules`, or stores a cache in the project. -For scripts and CI, use explicit flags instead of the wizard: +The six destination layouts are covered by filesystem tests. Native host +discovery remains **Experimental** until each real host is opened and confirms +that it discovers and follows the installed skill; file placement alone is not +a production host-discovery claim. + +For local dogfooding, scripts, and CI, use explicit flags instead of the +wizard. Do not commit the generated installation: ~~~sh -npx anchorloop install --project --platform codex -npx anchorloop install --global --platform gemini -npx anchorloop install --global --all -npx anchorloop install --global --all --preview +anchor install --project --platform codex --apply +anchor install --global --platform gemini --apply +anchor install --global --all --apply +anchor install --global --all ~~~ npm may keep its own user-level download cache; AnchorLoop never writes an npm @@ -156,6 +183,16 @@ This is a Git installation path, not a PyPI release. Afterward, run `anchor install ...` to add the optional portable skill adapter to a project or user skill directory. +## Migrating from 0.1.0 to 0.2.0 + +Do not delete `.anchor/`: it is the project workflow record. The `0.2.0` +migration refreshes managed protocol/support files and skill assets while +preserving task, rule, approval, and audit records. If an installed skill was +edited locally, review and preserve that diff before using `--force`. + +See the [0.1.0 to 0.2.0 migration guide](docs/MIGRATION_0.2.md) for the current +release-candidate procedure and the exact commands to use after publication. + ## Development from a checkout Requirements: Python 3.11 or newer. @@ -357,6 +394,8 @@ index separately during that migration. - [Decision map](docs/ANCHOR_DECISION_MAP.md) - [Domain glossary](CONTEXT.md) - [Portable skill adapter](docs/PORTABLE_SKILL.md) +- [Migration from 0.1.0 to 0.2.0](docs/MIGRATION_0.2.md) +- [Changelog](CHANGELOG.md) - [Contributing](CONTRIBUTING.md) - [Security policy](SECURITY.md) diff --git a/docs/ANCHOR_DECISION_MAP.md b/docs/ANCHOR_DECISION_MAP.md index 44e0924..82e76ad 100644 --- a/docs/ANCHOR_DECISION_MAP.md +++ b/docs/ANCHOR_DECISION_MAP.md @@ -1,15 +1,15 @@ # AnchorLoop release decision map -This map separates resolved 0.1 decisions from external release blockers and +This map separates resolved 0.2 decisions from external release gates and post-release product questions. -## Resolved for 0.1 +## Resolved for 0.2 | Decision | Resolution | |---|---| | Core runtime | Python 3.11+ owns workflow/state; the npm package is a thin Node 18+ launcher around bundled Python source. | | Source of truth | The CLI and `.anchor/` state, never a Codex-only skill, model, provider, or slash-command format. | -| Skill distribution | Project or user scope; generic `.agents/skills` and explicit `.codex/skills`; preview/apply and owned-file uninstall. | +| Skill distribution | Project or user scope across the six documented filesystem destinations; preview/apply and owned-file uninstall. Real-host discovery remains Experimental. | | Version source | `src/anchorloop/version.py`; Python metadata reads it and npm/tag values are checked against it. | | Mutation model | One interprocess lock plus validated redo journal and ordered event outbox for every project mutation. | | Quality fingerprint | Materialized tracked/non-ignored files, recursively including submodules; Git metadata is diagnostic only. | @@ -17,17 +17,18 @@ post-release product questions. | Cache/recovery state | Ignored local runtime data; never committed as workflow evidence. | | Trust statement | `--by` and interactive TTY confirmation are auditable provenance, not authenticated identity. | -## External release blocker +## Human-owned release gate -### npm name bootstrap +### Publish the immutable 0.2.0 version -`anchorloop` currently returns E404 in the public registry. A maintainer must -either reserve it with a lower, version-consistent bootstrap release (for -example `0.0.0`) or switch every manifest, runner, workflow, and document to an -owned scope. The bootstrap must not consume the intended alpha version: npm -versions are immutable, while `0.1.0` must be published from its signed tag -with OIDC provenance. No code path may claim that `npx anchorloop install` -works publicly before registry smoke succeeds. +`anchorloop@0.1.0` is published production. Current `main` is the unreleased +`0.2.0` release candidate. A maintainer must merge the green release PR, create +a GitHub-verified signed annotated `v0.2.0` tag, and push only that tag. The +workflow then verifies the canonical version, refuses an existing exact npm +version, publishes through OIDC, runs the registry lifecycle, and checks npm +`gitHead` against the tagged commit. No code path or document may claim that +the `0.2.0` guided installer is available from npm `latest` before this gate +succeeds. ## Decisions before public beta diff --git a/docs/MIGRATION_0.2.md b/docs/MIGRATION_0.2.md new file mode 100644 index 0000000..8f4232f --- /dev/null +++ b/docs/MIGRATION_0.2.md @@ -0,0 +1,88 @@ +# Migrating AnchorLoop from 0.1.0 to 0.2.0 + +## Release status + +- Published production: `anchorloop@0.1.0` +- Current repository: unreleased `0.2.0` release candidate + +Do not run the registry commands in this guide until `anchorloop@0.2.0` exists +and its registry smoke has passed. Before publication, use the development +checkout procedure below. + +## Compatibility contract + +Keep the project's `.anchor/` directory. It contains tasks, rules, approvals, +events, outcomes, and recovery state; neither package upgrade nor skill +installation should replace it. + +The `0.2.0` setup path: + +- appends missing managed ignore entries without removing custom lines; +- refreshes missing protocol and support files; +- preserves existing task, rule, approval, and audit records; +- continues to read legacy CAREFUL recall records; +- requires an explicit `rules supersede` action for legacy approved rules that + lack approval-time integrity evidence; +- refuses to overwrite a locally modified installed skill unless `--force` is + explicitly selected after reviewing the diff. + +## Before publication: test the release candidate from a checkout + +From the `release/0.2.0` checkout: + +~~~sh +python -m pip install -e . +anchor install --project --platform codex --apply +anchor add --apply +anchor doctor --strict +~~~ + +The generated `.codex/skills/anchorloop/` directory is local dogfooding state +and must not be committed. Use `--platform agents` instead of `codex` when the +project consumes the cross-framework Agent Skills location. + +## After publication: upgrade with the exact npm version + +From each existing AnchorLoop project: + +~~~sh +npx --yes anchorloop@0.2.0 install --project --platform codex --apply +npx --yes anchorloop@0.2.0 add --apply +npx --yes anchorloop@0.2.0 doctor --strict +~~~ + +Keep commands pinned to `@0.2.0` in automation and installed skill metadata. +Do not rely on npm `latest` during rollout. + +## Locally modified skill files + +If installation reports modified owned assets: + +1. inspect the existing skill directory and save its diff outside the managed + installation; +2. run installation again without `--force` to confirm the conflict; +3. merge any intentional instruction changes into project-owned documentation; +4. only then rerun the exact install command with `--force`. + +`--force` applies only to installer-owned skill assets. It does not authorize +deleting or replacing `.anchor/` workflow state. + +## Legacy approved rules + +A legacy approved rule without its approval-time document digest is not +silently trusted. Propose and approve a replacement in the same category, then +record the migration explicitly: + +~~~sh +npx --yes anchorloop@0.2.0 rules supersede \ + --by "Engineer name" \ + --reason "Migrate the legacy rule to an integrity-protected document." +~~~ + +## If validation fails + +Do not delete `.anchor/`, edit its JSON by hand, or downgrade state in place. +Preserve the checkout and the failing `doctor --strict` output, inspect +transaction/recovery guidance with `status` and `doctor`, and fix the release +candidate before creating the signed tag. npm versions and published tags must +never be overwritten. diff --git a/docs/PORTABLE_SKILL.md b/docs/PORTABLE_SKILL.md index d18a756..fce93f7 100644 --- a/docs/PORTABLE_SKILL.md +++ b/docs/PORTABLE_SKILL.md @@ -6,17 +6,37 @@ engine and it never becomes the source of truth for task state. ## Install -For Node.js 18+ and Python 3.11+, the npm shortcut is the fastest route: +Published production is `anchorloop@0.1.0`. Current `main` is the unreleased +`0.2.0` release candidate; its guided multi-agent installer is not available +from npm `latest` until the signed release workflow succeeds. + +For production use, pin the published package explicitly: + +~~~powershell +npx --yes anchorloop@0.1.0 install --project --platform codex --apply +~~~ + +To exercise the `0.2.0` candidate from a checkout, install the current source +and open the guided installer locally: ~~~powershell -npx anchorloop install +python -m pip install -e . +anchor install --interactive ~~~ -In an interactive terminal this opens a guided installer: choose the current -project or your user profile; user-global setup then offers Codex, Cursor, -Gemini CLI, Claude Code, OpenCode, the cross-framework Agent Skills standard, -or all native agent locations. The installer shows every destination and asks for a -final confirmation before it writes files. +In an interactive terminal choose the current project or your user profile; +user-global setup then offers Codex, Cursor, Gemini CLI, Claude Code, OpenCode, +the cross-framework Agent Skills standard, or all native agent locations. The +installer shows every destination and asks for final confirmation before it +writes files. + +## Compatibility status + +| Capability | Status | Evidence and boundary | +|---|---|---| +| Filesystem destination/install matrix for `agents`, `codex`, `cursor`, `gemini`, `claude`, and `opencode` | **Verified** | Automated tests verify exact placement, owned assets, update, and uninstall behavior. This does not verify any host. | +| Real-host skill discovery for every named host | **Experimental** | Each host must be opened and tested against its current release before discovery can be claimed. No host is marked Verified from filesystem placement alone. | +| Native adapters, hooks, MCP, and other undiscovered host integrations | **Planned** | These remain separate opt-in integrations and are not part of the `0.2.0` production claim. | Project setup uses `.agents/skills/anchorloop/`. Global setup writes the selected host's native directory, such as `~/.codex/skills/anchorloop/` or @@ -27,15 +47,19 @@ source, so it can execute later AnchorLoop commands without a globally installed `anchor` executable. It never writes `node_modules`, a Python bytecode cache, or a workflow cache into the project. -For repeatable scripts, choose scope and destination explicitly: +For repeatable development scripts, choose scope and destination explicitly: ~~~powershell -npx anchorloop install --project --platform codex -npx anchorloop install --global --platform gemini -npx anchorloop install --global --all -npx anchorloop install --global --all --preview +anchor install --project --platform codex --apply +anchor install --global --platform gemini --apply +anchor install --global --all --apply +anchor install --global --all ~~~ +After `0.2.0` is published, automation may use the equivalent exact-version +runner `npx --yes anchorloop@0.2.0 ...`. Keep release and automation commands +pinned even after npm `latest` moves. + For a project-scoped installation, AnchorLoop rejects symlink and Windows reparse-point components in `.codex/`, `.agents/`, `skills/`, and the skill directory. `--force` can replace only AnchorLoop-owned assets; it never relaxes @@ -74,9 +98,11 @@ replaced or removed. Install, update, and uninstall are serialized per destination and use a durable journal outside the project. The marker is committed last. If a process stops between asset writes, the next mutating installer command rolls the exact -operation forward before proceeding; read-only status reports -`recovery_pending` without changing files. A successful operation removes its -journal and does not leave a cache in the repository. +operation forward and then stops without starting the newly requested +mutation. Inspect the destination and rerun explicitly if it is still needed; +the read-only installer status API reports `recovery_pending` without changing +files. A successful operation removes its journal and does not leave a cache in +the repository. Uninstall removes only unchanged installer-owned files: @@ -103,6 +129,9 @@ migration-required, not silently trusted. The engineer must propose and approve a replacement in the same category, then record an explicit `anchor rules supersede --by --reason ` action. +For the complete package and project upgrade sequence, see +[Migration from 0.1.0 to 0.2.0](MIGRATION_0.2.md). + ## Workflow contract The installed skill directs an agent to run the installer-rendered command diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 3e6b2ce..74318c3 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -1,6 +1,6 @@ -# AnchorLoop 0.1 release plan +# AnchorLoop 0.2 release plan -Status: release candidate; public npm bootstrap pending +Status: `0.2.0` release candidate; `0.1.0` is published production Scope: local, agent-neutral workflow integrity and human-ownership loop ## Product intent @@ -13,11 +13,11 @@ thin discovery/instruction adapter around that same core. > The agent may write the code. The engineer owns why it exists, which > trade-off was chosen, what could fail, and how the result is verified. -## What 0.1 implements +## What the 0.2 candidate implements | Area | Release behavior | |---|---| -| Distribution | Standalone Python CLI plus an npm launcher that installs a Codex or generic Agent Skills adapter. | +| Distribution | Standalone Python CLI plus an npm launcher that installs the same thin skill adapter into the six documented destination layouts. | | Workflow | `start → brief → plan → approve → implement → review → precommit → verify → close`, with explicit revision after failure. | | Human ownership | AUTO risk recommendation, FAST/STANDARD/CAREFUL modes, engineer-authored artifact and comprehension fields, rollback mitigation for CAREFUL, immediate/delayed recall, post-completion outcomes, and local JSON/CSV report. | | Integrity | Approval digests bind the brief, brief attribution, plan, human-ownership record, recall policy, and active ruleset. | @@ -44,7 +44,7 @@ flowchart LR ~~~ The adapter never owns state. Graphify, native host plugins, hooks, MCP, and -external research are optional future integrations; 0.1 does not install or +external research are optional future integrations; 0.2 does not install or invoke them automatically. ## Human-ownership contract @@ -79,12 +79,17 @@ required before approval identity can be treated as an access-control claim. relaxes the filesystem boundary. 9. Generated cache and recovery internals are ignored and never committed as project evidence. +10. The actual Git diff is re-evaluated before review and precommit; CAREFUL + paths cannot proceed under a lower mode without a reviewed, audited + path-specific override. +11. Installer destination compatibility is not presented as verified native + host discovery without a real-host test. ## Supported command surface ~~~text anchor init|add [--apply] -anchor install|uninstall [--project] [--platform agents|codex] [--apply] +anchor install|uninstall [--project|--global] [--platform agents|codex|cursor|gemini|claude|opencode] [--apply] anchor status anchor doctor [--strict|--repair] anchor start, brief, plan, approve, implement, review, precommit @@ -93,9 +98,11 @@ anchor rules list|propose|approve|supersede anchor agent detect|setup|status ~~~ -The npm form runs the same surface through a pinned command such as -`npx --yes anchorloop@0.1.0`. README and the bundled skill contain the full -structured plan/verification examples. +After publication, the npm form runs the same surface through the pinned +`npx --yes anchorloop@0.2.0` command. During release-candidate development use +the editable checkout and `anchor`; `anchorloop@0.1.0` remains the production +npm package. README and the bundled skill contain the full structured +plan/verification examples. ## Production release gates @@ -112,13 +119,13 @@ Code is release-ready only when all of these are true: - the exact public registry version passes install, full task lifecycle, uninstall, and residue checks. -The unscoped `anchorloop` package does not yet exist in npm. Reserve it once -with a lower, version-consistent bootstrap version such as `0.0.0`; do not -manually consume the intended alpha version because npm cannot republish an -immutable version with provenance. Then configure the protected `npm-release` -environment and npm trusted publisher for `.github/workflows/release.yml` and -publish `0.1.0` from its signed tag. Later releases remain tag-driven and -OIDC-only. +The unscoped `anchorloop@0.1.0` package is already published. `0.2.0` must be +released only from a GitHub-verified signed annotated `v0.2.0` tag after the +full CI matrix passes. The protected `npm-release` environment and npm trusted +publisher for `.github/workflows/release.yml` must use OIDC without a +long-lived npm token. The workflow refuses an already-published exact version, +performs a registry-backed lifecycle smoke, and verifies that npm `gitHead` +matches the tagged commit. Do not create the tag or publish from a release PR. ## Pilot evidence diff --git a/docs/i18n/README.de.md b/docs/i18n/README.de.md index c77b988..7e7d443 100644 --- a/docs/i18n/README.de.md +++ b/docs/i18n/README.de.md @@ -2,6 +2,11 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) +> - **Veröffentlichte Produktion:** `anchorloop@0.1.0` +> - **Unveröffentlichtes main:** Release Candidate `0.2.0` +> - Bis zur Veröffentlichung von `0.2.0` Produktionsbefehle mit +> `npx --yes anchorloop@0.1.0 ...` ausführen. [Migrationsanleitung](../MIGRATION_0.2.md). + AnchorLoop ist ein lokaler Workflow für KI-Coding-Agents. Der Engineer behält die Kontrolle über Aufgaben, Regeln, Projektstruktur und die finale Abnahme. Grundsatz: Die KI darf Code schreiben, aber ihren Plan nicht selbst freigeben, keine neuen Regeln aktivieren, keine Skills auswählen und keine Aufgabe schließen. diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index 20fdbc7..4dc2715 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -2,6 +2,11 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) +> - **Producción publicada:** `anchorloop@0.1.0` +> - **main sin publicar:** candidato de versión `0.2.0` +> - Hasta publicar `0.2.0`, usa `npx --yes anchorloop@0.1.0 ...` en producción. +> [Guía de migración](../MIGRATION_0.2.md). + AnchorLoop es un workflow local para agentes de programación con IA. El ingeniero conserva el control de las tareas, las reglas, la estructura y la aceptación final. Principio: la IA puede escribir código, pero no puede aprobar su propio plan, activar reglas nuevas, elegir skills ni cerrar una tarea. diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index c722a84..c0794ae 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -2,6 +2,11 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) +> - **Production publiée :** `anchorloop@0.1.0` +> - **main non publié :** release candidate `0.2.0` +> - Jusqu'à la publication de `0.2.0`, utilisez +> `npx --yes anchorloop@0.1.0 ...` en production. [Guide de migration](../MIGRATION_0.2.md). + AnchorLoop est un workflow local pour les agents de programmation assistés par IA. L’ingénieur garde le contrôle des tâches, des règles, de la structure et de la validation finale. Principe : l’IA peut écrire du code, mais elle ne peut pas approuver son propre plan, activer de nouvelles règles, choisir des skills ou fermer une tâche. diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index e8428a8..32b882a 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -2,6 +2,11 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) +> - **公開済み production:** `anchorloop@0.1.0` +> - **未公開の main:** `0.2.0` release candidate +> - `0.2.0` が公開されるまでは production で +> `npx --yes anchorloop@0.1.0 ...` を使用してください。[移行ガイド](../MIGRATION_0.2.md)。 + AnchorLoop は、AI コーディングエージェントのためのローカル workflow です。エンジニアはタスク、ルール、プロジェクト構造、最終受け入れの管理を維持します。 原則: AI はコードを書けますが、自分の計画を承認したり、新しいルールを有効化したり、スキルを選択したり、タスクを完了したりできません。 diff --git a/docs/i18n/README.pt-BR.md b/docs/i18n/README.pt-BR.md index 2a038d7..a2ad1ac 100644 --- a/docs/i18n/README.pt-BR.md +++ b/docs/i18n/README.pt-BR.md @@ -2,6 +2,11 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) +> - **Produção publicada:** `anchorloop@0.1.0` +> - **main não publicado:** release candidate `0.2.0` +> - Até a publicação de `0.2.0`, use `npx --yes anchorloop@0.1.0 ...` em +> produção. [Guia de migração](../MIGRATION_0.2.md). + AnchorLoop é um workflow local para agentes de programação com IA. A pessoa engenheira mantém o controle das tarefas, das regras, da estrutura e da aceitação final. Princípio: a IA pode escrever código, mas não pode aprovar seu próprio plano, ativar novas regras, escolher skills ou fechar uma tarefa. diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index d840f91..79cd0b2 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -1,9 +1,9 @@ # AnchorLoop -> Статус: версия 0.1 подготовлена как release candidate для публичной alpha. -> Пакет `anchorloop` ещё не опубликован в npm, поэтому `npx anchorloop install` -> начнёт работать только после резервирования имени отдельной нижней -> bootstrap-версией, tagged-релиза `0.1.0` и registry smoke test. +> - **Опубликованный production:** `anchorloop@0.1.0` +> - **Неопубликованный main:** release candidate `0.2.0` +> - До публикации `0.2.0` используйте в production точную команду +> `npx --yes anchorloop@0.1.0 ...`. [Инструкция по миграции](../MIGRATION_0.2.md). [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) diff --git a/docs/i18n/README.zh-CN.md b/docs/i18n/README.zh-CN.md index 1613c49..569b417 100644 --- a/docs/i18n/README.zh-CN.md +++ b/docs/i18n/README.zh-CN.md @@ -2,6 +2,11 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) +> - **已发布的 production:** `anchorloop@0.1.0` +> - **尚未发布的 main:** `0.2.0` release candidate +> - 在 `0.2.0` 发布前,production 请使用 +> `npx --yes anchorloop@0.1.0 ...`。[迁移指南](../MIGRATION_0.2.md)。 + AnchorLoop 是面向 AI 编程代理的本地 workflow。工程师始终掌控任务、规则、项目结构和最终验收。 核心原则:AI 可以编写代码,但不能自行批准计划、启用新规则、选择技能或关闭任务。 diff --git a/npm/scripts/registry-smoke.js b/npm/scripts/registry-smoke.js index a9160bd..bd779ac 100644 --- a/npm/scripts/registry-smoke.js +++ b/npm/scripts/registry-smoke.js @@ -115,6 +115,7 @@ function runSmoke({ packageName, version }) { assert.match(fs.readFileSync(skillPath, "utf8"), new RegExp(`npx --yes ${packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@${version} status`)); anchor("add", "--apply"); + anchor("doctor", "--strict"); for (const ignored of [ "cache/example", ".cache/example", @@ -186,13 +187,20 @@ function runSmoke({ packageName, version }) { "--notes", "No post-completion issue in the registry smoke window.", ); anchor("report", "--format", "json"); + anchor("doctor", "--strict"); anchor("uninstall"); + anchor("doctor", "--strict"); assert.equal(fs.existsSync(skillPath), false, "uninstall left the installed skill behind"); assert.equal(fs.existsSync(path.join(project, ".anchor")), true, "workflow state disappeared during uninstall"); for (const forbidden of [".agents", "node_modules", "cache", ".cache", ".npm", ".npm-cache", "__pycache__"]) { assert.equal(fs.existsSync(path.join(project, forbidden)), false, `${forbidden} leaked into the project`); } + for (const relative of [".anchor/transactions/pending", ".anchor/outbox"]) { + const directory = path.join(project, ...relative.split("/")); + assert.equal(fs.existsSync(directory), true, `${relative} is missing after the lifecycle`); + assert.deepEqual(fs.readdirSync(directory), [], `${relative} retained recovery residue`); + } console.log(`${packageSpec}: clean registry lifecycle passed.`); } finally { fs.rmSync(workspace, { recursive: true, force: true }); diff --git a/npm/scripts/version.js b/npm/scripts/version.js index 7ee9d78..4747f03 100644 --- a/npm/scripts/version.js +++ b/npm/scripts/version.js @@ -42,13 +42,43 @@ function assertVersionConsistency(options = {}) { const version = readCanonicalVersion(root); const packagePath = path.join(root, "package.json"); const packageMetadata = JSON.parse(fs.readFileSync(packagePath, "utf8")); - assertPackageName(packageMetadata.name); + const packageName = assertPackageName(packageMetadata.name); if (packageMetadata.version !== version) { throw new Error( `package.json version ${packageMetadata.version} does not match canonical version ${version}.`, ); } + if (packageMetadata.dependencies?.[packageName]) { + throw new Error("AnchorLoop must not depend on itself."); + } + + const lockPath = path.join(root, "package-lock.json"); + const packageLock = JSON.parse(fs.readFileSync(lockPath, "utf8")); + if ( + packageLock.version !== version + || packageLock.packages?.[""]?.version !== version + ) { + throw new Error("package-lock.json version does not match the canonical version."); + } + if ( + packageLock.packages?.["node_modules/anchorloop"] + || packageLock.packages?.[""]?.dependencies?.anchorloop + ) { + throw new Error("package-lock.json contains a recursive AnchorLoop dependency."); + } + + const generatedSkillMarker = path.join( + root, + ".codex", + "skills", + "anchorloop", + ".anchorloop-skill.json", + ); + if (fs.existsSync(generatedSkillMarker)) { + throw new Error("The generated local AnchorLoop skill must not be present in a release checkout."); + } + const pyprojectPath = path.join(root, "pyproject.toml"); const pyproject = fs.readFileSync(pyprojectPath, "utf8"); if (/^version\s*=\s*"/m.test(pyproject)) { @@ -64,7 +94,7 @@ function assertVersionConsistency(options = {}) { if (releaseTag && releaseTag !== `v${version}`) { throw new Error(`Release tag ${releaseTag} does not match canonical version v${version}.`); } - return { packageName: packageMetadata.name, version }; + return { packageName, version }; } function main() { diff --git a/npm/test/anchorloop.test.js b/npm/test/anchorloop.test.js index 2b7efb7..1b83196 100644 --- a/npm/test/anchorloop.test.js +++ b/npm/test/anchorloop.test.js @@ -170,3 +170,73 @@ test("the package manifest allowlists the runner and its Python skill assets", ( /https:\/\/raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/main\/docs\/assets\/anchorloop-evidence-integrity\.svg/, ); }); + +test("the actual npm tarball contains only release runtime and documentation files", { timeout: 60_000 }, () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-pack-")); + const npmCache = path.join(temporaryRoot, "npm-cache"); + try { + const npmArguments = ["pack", "--pack-destination", temporaryRoot, "--json"]; + const npmExecutable = process.env.npm_execpath; + const pack = npmExecutable + ? spawnSync(process.execPath, [npmExecutable, ...npmArguments], { + cwd: repositoryRoot, + encoding: "utf8", + shell: false, + windowsHide: true, + env: { ...process.env, npm_config_cache: npmCache }, + }) + : spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", npmArguments, { + cwd: repositoryRoot, + encoding: "utf8", + shell: process.platform === "win32", + windowsHide: true, + env: { ...process.env, npm_config_cache: npmCache }, + }); + assert.equal(pack.status, 0, `${pack.stdout}\n${pack.stderr}`); + const jsonStart = pack.stdout.indexOf("["); + const jsonEnd = pack.stdout.lastIndexOf("]"); + assert(jsonStart >= 0 && jsonEnd > jsonStart, `npm pack did not return JSON:\n${pack.stdout}`); + const metadata = JSON.parse(pack.stdout.slice(jsonStart, jsonEnd + 1)); + assert.equal(metadata.length, 1, "npm pack must create exactly one archive"); + const archives = fs.readdirSync(temporaryRoot).filter((name) => name.endsWith(".tgz")); + assert.deepEqual(archives, [metadata[0].filename]); + + const archive = path.join(temporaryRoot, metadata[0].filename); + const listing = spawnSync("tar", ["-tzf", archive], { + cwd: temporaryRoot, + encoding: "utf8", + shell: false, + windowsHide: true, + }); + assert.equal(listing.status, 0, listing.stderr); + const entries = listing.stdout.split(/\r?\n/).filter(Boolean); + const forbidden = [ + /^package\/node_modules\//, + /^package\/\.codex\//, + /^package\/\.anchor\//, + /(?:^|\/)__pycache__\//, + /\.pyc$/, + /(?:^|\/)[^/]+\.egg-info\//, + /^package\/(?:cache|\.npm|\.npm-cache)\//, + /(?:^|\/)(?:tests?|fixtures?)\//, + ]; + for (const entry of entries) { + assert.equal( + forbidden.some((pattern) => pattern.test(entry)), + false, + `forbidden release archive entry: ${entry}`, + ); + } + for (const required of [ + "package/package.json", + "package/CHANGELOG.md", + "package/docs/MIGRATION_0.2.md", + "package/src/anchorloop/skills/anchorloop/SKILL.md", + "package/src/anchorloop/skills/anchorloop/references/workflow.md", + ]) { + assert(entries.includes(required), `missing release archive entry: ${required}`); + } + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/npm/test/version.test.js b/npm/test/version.test.js index 9f9351a..44e5f8e 100644 --- a/npm/test/version.test.js +++ b/npm/test/version.test.js @@ -10,6 +10,31 @@ const repositoryRoot = path.resolve(__dirname, "..", ".."); const { assertPackageName, assertVersionConsistency, readCanonicalVersion } = require("../scripts/version.js"); const { parseArguments } = require("../scripts/registry-smoke.js"); +function writeReleaseFixture(root, { packageMetadata, packageLock } = {}) { + fs.mkdirSync(path.join(root, "src", "anchorloop"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "anchorloop", "version.py"), 'VERSION = "1.2.3"\n', "utf8"); + fs.writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify(packageMetadata || { name: "anchorloop", version: "1.2.3" })}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(root, "package-lock.json"), + `${JSON.stringify(packageLock || { + name: "anchorloop", + version: "1.2.3", + lockfileVersion: 3, + packages: { "": { name: "anchorloop", version: "1.2.3" } }, + })}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(root, "pyproject.toml"), + '[project]\ndynamic = ["version"]\n[tool.setuptools.dynamic]\nversion = { attr = "anchorloop.version.VERSION" }\n', + "utf8", + ); +} + test("the canonical version drives both package formats and the release tag", () => { const version = readCanonicalVersion(repositoryRoot); assert.deepEqual( @@ -25,20 +50,79 @@ test("the canonical version drives both package formats and the release tag", () test("a stale npm manifest cannot pass the release version gate", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-version-")); try { - fs.mkdirSync(path.join(root, "src", "anchorloop"), { recursive: true }); - fs.writeFileSync(path.join(root, "src", "anchorloop", "version.py"), 'VERSION = "1.2.3"\n', "utf8"); - fs.writeFileSync(path.join(root, "package.json"), '{"name":"anchorloop","version":"1.2.4"}\n', "utf8"); - fs.writeFileSync( - path.join(root, "pyproject.toml"), - '[project]\ndynamic = ["version"]\n[tool.setuptools.dynamic]\nversion = { attr = "anchorloop.version.VERSION" }\n', - "utf8", - ); + writeReleaseFixture(root, { packageMetadata: { name: "anchorloop", version: "1.2.4" } }); assert.throws(() => assertVersionConsistency({ root }), /does not match canonical version/); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); +test("release metadata cannot depend on AnchorLoop itself", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-version-")); + try { + writeReleaseFixture(root, { + packageMetadata: { + name: "anchorloop", + version: "1.2.3", + dependencies: { anchorloop: "^1.2.0" }, + }, + }); + assert.throws(() => assertVersionConsistency({ root }), /must not depend on itself/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("release lockfile cannot contain a recursive AnchorLoop package", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-version-")); + try { + writeReleaseFixture(root, { + packageLock: { + name: "anchorloop", + version: "1.2.3", + lockfileVersion: 3, + packages: { + "": { name: "anchorloop", version: "1.2.3" }, + "node_modules/anchorloop": { version: "1.2.2" }, + }, + }, + }); + assert.throws(() => assertVersionConsistency({ root }), /recursive AnchorLoop dependency/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("a stale lockfile version cannot pass the release gate", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-version-")); + try { + writeReleaseFixture(root, { + packageLock: { + name: "anchorloop", + version: "1.2.2", + lockfileVersion: 3, + packages: { "": { name: "anchorloop", version: "1.2.2" } }, + }, + }); + assert.throws(() => assertVersionConsistency({ root }), /lock.json version/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("release checkout rejects a generated local skill installation", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-version-")); + try { + writeReleaseFixture(root); + const marker = path.join(root, ".codex", "skills", "anchorloop", ".anchorloop-skill.json"); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, "{}\n", "utf8"); + assert.throws(() => assertVersionConsistency({ root }), /generated local AnchorLoop skill/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("ambiguous canonical version assignments are rejected", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-version-")); try { diff --git a/package-lock.json b/package-lock.json index 76ec44c..0e1bf65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,12 @@ { "name": "anchorloop", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anchorloop", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "anchorloop": "^0.1.0" - }, - "bin": { - "anchorloop": "npm/bin/anchorloop.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/anchorloop": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/anchorloop/-/anchorloop-0.1.0.tgz", - "integrity": "sha512-M1r6BXnmzKuyUdHRH3loUJBhX/llCRwltt7V6ydVy5NkLYnNYJzTblxf+f9MsHPK9DfPIGK5W0QuaXX3LKfy+w==", + "version": "0.2.0", "license": "MIT", "bin": { "anchorloop": "npm/bin/anchorloop.js" diff --git a/package.json b/package.json index b50e1b0..41df5ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anchorloop", - "version": "0.1.0", + "version": "0.2.0", "description": "Engineer-controlled, agent-neutral workflow for AI-assisted software delivery.", "license": "MIT", "repository": { @@ -38,6 +38,7 @@ "src/anchorloop/skills/", "pyproject.toml", "README.md", + "CHANGELOG.md", "CONTEXT.md", "CONTRIBUTING.md", "SECURITY.md", @@ -51,8 +52,5 @@ "prepack": "npm run version:check", "pack:check": "npm pack --dry-run", "registry:smoke": "node npm/scripts/registry-smoke.js" - }, - "dependencies": { - "anchorloop": "^0.1.0" } } diff --git a/src/anchorloop/cli.py b/src/anchorloop/cli.py index 4528316..d127961 100644 --- a/src/anchorloop/cli.py +++ b/src/anchorloop/cli.py @@ -734,6 +734,19 @@ def _apply_skill_installations( ) -> int: installations = [] failures: list[tuple[str, AnchorError]] = [] + for platform in platforms: + try: + installer.recover_pending( + platform=platform, + project_scoped=project_scoped, + requested_action="install", + ) + except AnchorError as error: + failures.append((platform, error)) + if failures: + _print_skill_install_results(installations, failures) + return 2 + for platform in platforms: try: installations.append( @@ -761,6 +774,19 @@ def _apply_skill_uninstallations( ) -> int: installations = [] failures: list[tuple[str, AnchorError]] = [] + for platform in platforms: + try: + installer.recover_pending( + platform=platform, + project_scoped=project_scoped, + requested_action="uninstall", + ) + except AnchorError as error: + failures.append((platform, error)) + if failures: + _print_skill_uninstall_results(installations, failures) + return 2 + for platform in platforms: try: installations.append( diff --git a/src/anchorloop/project.py b/src/anchorloop/project.py index 92f329e..d2378d4 100644 --- a/src/anchorloop/project.py +++ b/src/anchorloop/project.py @@ -14,7 +14,12 @@ from uuid import uuid4 from .command import display_command -from .quality import run_precommit, workspace_fingerprint +from .quality import ( + GitInspectionError, + git_changed_path_names, + run_precommit, + workspace_fingerprint, +) from .project_lock import ProjectLock from .safe_fs import AnchorError, SafeProjectFS from .transaction import ProjectTransaction, TransactionManager @@ -164,6 +169,8 @@ ), } +_ACTUAL_DIFF_REVISION_REASON = "Actual diff introduced CAREFUL risk paths." + _ReturnT = TypeVar("_ReturnT") @@ -1227,6 +1234,8 @@ def transition(self, action: str) -> dict[str, Any]: raise AnchorError(f"Cannot run '{action}' while task is '{state}'.{detail}") if action in {"implement", "review", "precommit", "close"}: self._ensure_approval_matches_task(task) + if action == "review": + self._ensure_actual_diff_mode(task) if action == "close": verification = task.get("verification", {}) if verification.get("result") not in {"pass", "partial", "not-applicable"}: @@ -1292,6 +1301,7 @@ def precommit(self) -> dict[str, Any]: if task["state"] != "review_ready": raise AnchorError(f"Cannot run 'precommit' while task is '{task['state']}'.") self._ensure_approval_matches_task(task) + self._ensure_actual_diff_mode(task) ruleset = task.get("ruleset") or {"rules": {}} quality = run_precommit(self.root, active_categories=set(ruleset["rules"])) task.setdefault("quality", []).append(quality) @@ -1848,6 +1858,97 @@ def _recommended_mode( return "STANDARD", f"task type {task_type!r} changes product or code behavior" return "STANDARD", "unknown task risk defaults to engineer-owned STANDARD planning" + def _ensure_actual_diff_mode(self, task: dict[str, Any]) -> None: + """Block a low-ownership task when its material diff is CAREFUL.""" + + if task.get("mode") == "CAREFUL": + return + risk_paths = self._actual_diff_risk_paths() + if not risk_paths: + return + + plan = task.get("plan") + recommendation = ( + plan.get("mode_recommendation") + if isinstance(plan, dict) + else None + ) + override = ( + recommendation.get("override_reason") + if isinstance(recommendation, dict) + else None + ) + if ( + self._actual_diff_override_covers(override, risk_paths) + and self._has_actual_diff_revision(task) + ): + return + + locations = ", ".join(risk_paths) + revise = display_command( + 'revise --target plan --reason ' + f'"{_ACTUAL_DIFF_REVISION_REASON}"' + ) + raise AnchorError( + f"Actual diff introduced CAREFUL risk paths: {locations}. " + f"Task mode {task.get('mode')} cannot continue. Run: {revise}. " + "Record and approve a new plan. Keeping a lower mode requires an " + "engineer-owned --mode-override-reason that explicitly lists " + "every detected path." + ) + + def _actual_diff_risk_paths(self) -> list[str]: + risky: list[str] = [] + try: + changed_paths = git_changed_path_names(self.root, strict=True) + except GitInspectionError as error: + raise AnchorError( + "Cannot evaluate the actual Git diff safely; review and precommit are blocked. " + "Restore Git access and retry." + ) from error + for path in changed_paths: + path_text = "/" + path.casefold().strip("/") + if any( + marker.casefold() in path_text + for markers in _CAREFUL_PATH_SIGNALS.values() + for marker in markers + ): + risky.append(path) + return risky + + @staticmethod + def _has_actual_diff_revision(task: dict[str, Any]) -> bool: + revisions = task.get("revisions") + if not isinstance(revisions, list): + return False + return any( + isinstance(revision, dict) + and revision.get("target") == "plan" + and revision.get("reason") == _ACTUAL_DIFF_REVISION_REASON + and revision.get("previous_state") + in {"implementing", "review_ready", "quality_checked"} + for revision in revisions + ) + + @staticmethod + def _actual_diff_override_covers( + override: object, + risk_paths: list[str], + ) -> bool: + if not isinstance(override, str) or not override.strip(): + return False + normalized = override.replace("\\", "/").casefold() + path_character = r"[\w./-]" + return all( + re.search( + rf"(? Iterator[None]: if self._mutation_depth: diff --git a/src/anchorloop/quality.py b/src/anchorloop/quality.py index afe4855..221205c 100644 --- a/src/anchorloop/quality.py +++ b/src/anchorloop/quality.py @@ -17,6 +17,10 @@ ) +class GitInspectionError(RuntimeError): + """A required Git working-tree query could not be completed.""" + + def run_precommit(root: Path, *, active_categories: set[str]) -> dict[str, Any]: """Run small, local checks that are safe before project-specific tools exist.""" @@ -409,24 +413,49 @@ def _source_files(root: Path) -> list[Path]: return [path for path in root.rglob("*") if _is_supported_source_file(root, path)] -def _git_changed_paths(root: Path) -> list[Path]: +def git_changed_path_names(root: Path, *, strict: bool = False) -> list[str]: + """Return normalized changed paths, including deleted files.""" + + git_root = _git_bytes(root, "rev-parse", "--show-toplevel") + if git_root is None: + if strict: + raise GitInspectionError( + "Required Git inspection failed: git rev-parse --show-toplevel" + ) + return [] commands = ( - ("diff", "--name-only", "HEAD"), - ("diff", "--cached", "--name-only"), - ("ls-files", "--others", "--exclude-standard"), + ("diff", "--relative", "--no-renames", "--name-only", "-z", "--"), + ("diff", "--cached", "--relative", "--no-renames", "--name-only", "-z", "--"), + ("ls-files", "--others", "--exclude-standard", "-z", "--"), ) - paths: dict[Path, None] = {} + paths: set[str] = set() for arguments in commands: - process = subprocess.run( - ["git", *arguments], cwd=root, capture_output=True, text=True, check=False - ) - if process.returncode != 0: + output = _git_bytes(root, *arguments) + if output is None: + if strict: + command = "git " + " ".join(arguments) + raise GitInspectionError(f"Required Git inspection failed: {command}") continue - for relative_name in process.stdout.splitlines(): - path = root / relative_name - if _is_regular_file(path): - paths[path] = None - return list(paths) + for encoded_name in output.split(b"\0"): + if not encoded_name: + continue + name = encoded_name.decode( + "utf-8", errors="surrogateescape" + ).replace("\\", "/") + while name.startswith("./"): + name = name[2:] + if not name or name == ".anchor" or name.startswith(".anchor/"): + continue + paths.add(name) + return sorted(paths) + + +def _git_changed_paths(root: Path) -> list[Path]: + paths = [ + root / relative_name + for relative_name in git_changed_path_names(root) + ] + return [path for path in paths if _is_regular_file(path)] def _is_supported_source_file(root: Path, path: Path) -> bool: diff --git a/src/anchorloop/skill_install.py b/src/anchorloop/skill_install.py index dc7758f..a6aca3c 100644 --- a/src/anchorloop/skill_install.py +++ b/src/anchorloop/skill_install.py @@ -11,7 +11,7 @@ from functools import wraps from importlib.resources import files from pathlib import Path -from typing import Any, Callable, TypeVar +from typing import Any, Callable, NoReturn, TypeVar from .project_lock import ProjectLock from .safe_fs import AnchorError, SafeProjectFS @@ -76,7 +76,7 @@ class SkillPlatform: SUPPORTED_SKILL_RUNTIMES = (SKILL_RUNTIME_ANCHOR, SKILL_RUNTIME_NPX) _MARKER_NAME = ".anchorloop-skill.json" _INSTALL_JOURNAL_NAME = "skill-install-journal.json" -_INSTALL_JOURNAL_SCHEMA = 1 +_INSTALL_JOURNAL_SCHEMA = 2 _NPX_PACKAGE_PATTERN = re.compile( r"(?:[a-z0-9][a-z0-9._-]*|@[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*)" r"@[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$" @@ -194,6 +194,10 @@ class SkillInstallation: runtime: str +class SkillRecoveryCompleted(AnchorError): + """A pending installer transaction was recovered instead of starting new work.""" + + class SkillInstaller: """Install the packaged thin agent adapter without owning workflow state.""" @@ -239,6 +243,36 @@ def _filesystem_for(self, project_scoped: bool) -> SafeProjectFS: return self._project_fs return SafeProjectFS(Path.home()) + @installation_locked("recover") + def recover_pending( + self, + *, + platform: str, + project_scoped: bool, + requested_action: str, + ) -> None: + """Finish durable recovery before a multi-destination batch mutates anything.""" + + if requested_action not in {"install", "uninstall"}: + raise AnchorError("Skill recovery requires an install or uninstall action.") + filesystem = self._filesystem_for(project_scoped) + destination = self.destination_for( + platform=platform, + project_scoped=project_scoped, + ) + recovered = self._recover_pending_installation( + filesystem, + destination, + platform=platform, + project_scoped=project_scoped, + ) + if recovered is not None: + self._raise_recovery_completed( + recovered, + destination, + requested_action=requested_action, + ) + @installation_locked("install") def install( self, @@ -249,15 +283,21 @@ def install( npx_package: str | None = None, force: bool = False, ) -> SkillInstallation: - self._validate_runtime(runtime, npx_package) filesystem = self._filesystem_for(project_scoped) destination = self.destination_for(platform=platform, project_scoped=project_scoped) - self._recover_pending_installation( + recovered = self._recover_pending_installation( filesystem, destination, platform=platform, project_scoped=project_scoped, ) + if recovered is not None: + self._raise_recovery_completed( + recovered, + destination, + requested_action="install", + ) + self._validate_runtime(runtime, npx_package) marker_path = self._safe_child(filesystem, destination, Path(_MARKER_NAME)) if filesystem.exists(destination) and not filesystem.exists(marker_path) and not force: raise AnchorError( @@ -301,6 +341,7 @@ def install( marker["npx_package"] = npx_package marker_content = (json.dumps(marker, indent=2, sort_keys=True) + "\n").encode("utf-8") journal = self._install_journal( + filesystem=filesystem, destination=destination, platform=platform, project_scoped=project_scoped, @@ -340,17 +381,11 @@ def uninstall( platform=platform, project_scoped=project_scoped, ) - if ( - recovered is not None - and recovered["action"] == "uninstall" - and not filesystem.exists(marker_path) - ): - return SkillInstallation( - destination=destination, - platform=platform, - project_scoped=project_scoped, - version=str(recovered["version"]), - runtime=str(recovered["runtime"]), + if recovered is not None: + self._raise_recovery_completed( + recovered, + destination, + requested_action="uninstall", ) if not filesystem.exists(marker_path): raise AnchorError(f"No AnchorLoop skill installation is recorded at {destination}.") @@ -363,6 +398,7 @@ def uninstall( paths = self._marker_files(marker, marker_path) self._require_unmodified_assets(filesystem, destination, paths, force=force, operation="uninstall") journal = self._uninstall_journal( + filesystem=filesystem, destination=destination, platform=platform, project_scoped=project_scoped, @@ -444,16 +480,23 @@ def _journal_context(destination: Path) -> tuple[SafeProjectFS, Path]: return filesystem, filesystem.path(".anchor", _INSTALL_JOURNAL_NAME) @staticmethod - def _encoded_file(relative_path: str, content: bytes) -> dict[str, str]: + def _encoded_file( + relative_path: str, + content: bytes, + *, + before: dict[str, Any], + ) -> dict[str, Any]: return { "path": relative_path, "sha256": SkillInstaller._digest(content), "content_b64": base64.b64encode(content).decode("ascii"), + "before": before, } def _install_journal( self, *, + filesystem: SafeProjectFS, destination: Path, platform: str, project_scoped: bool, @@ -472,16 +515,40 @@ def _install_journal( "version": version, "runtime": runtime, "writes": [ - self._encoded_file(path.as_posix(), content) + self._encoded_file( + path.as_posix(), + content, + before=self._path_state( + filesystem, + self._safe_child(filesystem, destination, path), + ), + ) for path, content in asset_files ], - "deletes": sorted(obsolete_files), - "marker": self._encoded_file(_MARKER_NAME, marker_content), + "deletes": [ + { + "path": relative_name, + "before": self._path_state( + filesystem, + self._safe_child(filesystem, destination, Path(relative_name)), + ), + } + for relative_name in sorted(obsolete_files) + ], + "marker": self._encoded_file( + _MARKER_NAME, + marker_content, + before=self._path_state( + filesystem, + self._safe_child(filesystem, destination, Path(_MARKER_NAME)), + ), + ), } - @staticmethod def _uninstall_journal( + self, *, + filesystem: SafeProjectFS, destination: Path, platform: str, project_scoped: bool, @@ -498,8 +565,27 @@ def _uninstall_journal( "version": version, "runtime": runtime, "writes": [], - "deletes": sorted(owned_files, key=lambda value: value.count("/"), reverse=True), - "marker": None, + "deletes": [ + { + "path": relative_name, + "before": self._path_state( + filesystem, + self._safe_child(filesystem, destination, Path(relative_name)), + ), + } + for relative_name in sorted( + owned_files, + key=lambda value: value.count("/"), + reverse=True, + ) + ], + "marker": { + "path": _MARKER_NAME, + "before": self._path_state( + filesystem, + self._safe_child(filesystem, destination, Path(_MARKER_NAME)), + ), + }, } def _commit_install_journal( @@ -548,6 +634,19 @@ def _recover_pending_installation( journal_filesystem.unlink(journal_path) return journal + @staticmethod + def _raise_recovery_completed( + journal: dict[str, Any], + destination: Path, + *, + requested_action: str, + ) -> NoReturn: + raise SkillRecoveryCompleted( + f"Recovered interrupted skill {journal['action']} at {destination}. " + f"No new {requested_action} was started. Inspect the destination, then rerun " + "the command if the requested change is still needed." + ) + def _pending_installation_status( self, filesystem: SafeProjectFS, @@ -617,7 +716,11 @@ def _decode_install_journal( journal: dict[str, Any], filesystem: SafeProjectFS, destination: Path, - ) -> tuple[list[tuple[Path, bytes]], list[Path], bytes | None]: + ) -> tuple[ + list[tuple[Path, bytes, dict[str, Any]]], + list[tuple[Path, dict[str, Any]]], + tuple[Path, bytes | None, dict[str, Any]], + ]: if journal.get("schema_version") != _INSTALL_JOURNAL_SCHEMA: raise AnchorError("Skill recovery journal schema is unsupported.") action = journal.get("action") @@ -635,32 +738,40 @@ def _decode_install_journal( if not isinstance(raw_writes, list) or not isinstance(raw_deletes, list): raise AnchorError("Skill recovery journal operations are invalid.") - writes: list[tuple[Path, bytes]] = [] + writes: list[tuple[Path, bytes, dict[str, Any]]] = [] write_names: set[str] = set() for entry in raw_writes: - relative_path, content = self._decode_journal_file(entry, filesystem, destination) + relative_path, content, before = self._decode_journal_file( + entry, + filesystem, + destination, + ) name = relative_path.as_posix() if name == _MARKER_NAME or name in write_names: raise AnchorError("Skill recovery journal contains duplicate or reserved writes.") write_names.add(name) - writes.append((relative_path, content)) + writes.append((relative_path, content, before)) - deletes: list[Path] = [] + deletes: list[tuple[Path, dict[str, Any]]] = [] delete_names: set[str] = set() - for value in raw_deletes: - if not isinstance(value, str): + for entry in raw_deletes: + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): raise AnchorError("Skill recovery journal delete path is invalid.") - relative_path = self._journal_relative_path(value, filesystem, destination) + relative_path = self._journal_relative_path(entry["path"], filesystem, destination) + before = self._decode_before_state(entry.get("before")) name = relative_path.as_posix() if name == _MARKER_NAME or name in delete_names or name in write_names: raise AnchorError("Skill recovery journal contains duplicate or conflicting deletes.") delete_names.add(name) - deletes.append(relative_path) + deletes.append((relative_path, before)) raw_marker = journal.get("marker") - marker_content: bytes | None if action == "install": - marker_path, marker_content = self._decode_journal_file(raw_marker, filesystem, destination) + marker_path, marker_content, marker_before = self._decode_journal_file( + raw_marker, + filesystem, + destination, + ) if marker_path.as_posix() != _MARKER_NAME: raise AnchorError("Skill recovery journal marker path is invalid.") try: @@ -680,23 +791,30 @@ def _decode_install_journal( marker_files = self._marker_files(marker, destination / _MARKER_NAME) expected_files = { relative_path.as_posix(): self._digest(content) - for relative_path, content in writes + for relative_path, content, _ in writes } if marker_files != expected_files: raise AnchorError("Skill recovery journal marker does not match its writes.") else: - if raw_marker is not None or writes: + if writes or not isinstance(raw_marker, dict): raise AnchorError("Skill uninstall recovery journal contains writes.") + marker_name = raw_marker.get("path") + if not isinstance(marker_name, str): + raise AnchorError("Skill recovery journal marker path is invalid.") + marker_path = self._journal_relative_path(marker_name, filesystem, destination) + if marker_path.as_posix() != _MARKER_NAME: + raise AnchorError("Skill recovery journal marker path is invalid.") + marker_before = self._decode_before_state(raw_marker.get("before")) marker_content = None - return writes, deletes, marker_content + return writes, deletes, (marker_path, marker_content, marker_before) def _decode_journal_file( self, entry: Any, filesystem: SafeProjectFS, destination: Path, - ) -> tuple[Path, bytes]: + ) -> tuple[Path, bytes, dict[str, Any]]: if not isinstance(entry, dict): raise AnchorError("Skill recovery journal file entry is invalid.") name = entry.get("path") @@ -711,7 +829,30 @@ def _decode_journal_file( raise AnchorError("Skill recovery journal contains invalid encoded content.") from error if self._digest(content) != digest: raise AnchorError("Skill recovery journal content digest is invalid.") - return relative_path, content + before = self._decode_before_state(entry.get("before")) + return relative_path, content, before + + @staticmethod + def _decode_before_state(value: Any) -> dict[str, Any]: + if value == {"kind": "missing"}: + return value + if not isinstance(value, dict) or value.get("kind") != "file": + raise AnchorError("Skill recovery journal before-state is invalid.") + digest = value.get("sha256") + identity = value.get("identity") + identity_keys = {"device", "inode", "mode", "size", "mtime_ns", "ctime_ns"} + if ( + not isinstance(digest, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None + or not isinstance(identity, dict) + or set(identity) != identity_keys + or any( + not isinstance(identity[key], int) or isinstance(identity[key], bool) + for key in identity_keys + ) + ): + raise AnchorError("Skill recovery journal before-state is invalid.") + return value def _journal_relative_path( self, @@ -733,34 +874,139 @@ def _apply_install_journal( destination: Path, journal: dict[str, Any], ) -> None: - writes, deletes, marker_content = self._decode_install_journal(journal, filesystem, destination) - marker_path = self._safe_child(filesystem, destination, Path(_MARKER_NAME)) + writes, deletes, marker = self._decode_install_journal(journal, filesystem, destination) + marker_relative_path, marker_content, marker_before = marker + marker_path = self._safe_child(filesystem, destination, marker_relative_path) - for relative_path, content in writes: + pending_writes: list[tuple[Path, bytes, dict[str, Any]]] = [] + for relative_path, content, before in writes: target = self._safe_child(filesystem, destination, relative_path) - self._write_bytes(filesystem, target, content) + if self._write_requires_apply(filesystem, target, content, before): + pending_writes.append((target, content, before)) - for relative_path in deletes: + pending_deletes: list[tuple[Path, dict[str, Any]]] = [] + for relative_path, before in deletes: target = self._safe_child(filesystem, destination, relative_path) - if filesystem.exists(target): - if not filesystem.is_file(target): - raise AnchorError(f"Managed skill asset must be a regular file: {target}") - filesystem.unlink(target) + if self._delete_requires_apply(filesystem, target, before): + pending_deletes.append((target, before)) + + if journal["action"] == "install": + assert marker_content is not None + marker_pending = self._write_requires_apply( + filesystem, + marker_path, + marker_content, + marker_before, + ) + else: + marker_pending = self._delete_requires_apply(filesystem, marker_path, marker_before) + + for target, content, before in pending_writes: + self._require_before_state(filesystem, target, before) + self._write_bytes(filesystem, target, content) + + for target, before in pending_deletes: + self._require_before_state(filesystem, target, before) + filesystem.unlink(target) filesystem.remove_empty_parents(target.parent, destination) if journal["action"] == "install": assert marker_content is not None # The marker is the commit record and is deliberately written only # after every owned asset matches the journaled bundle. - self._write_bytes(filesystem, marker_path, marker_content) + if marker_pending: + self._require_before_state(filesystem, marker_path, marker_before) + self._write_bytes(filesystem, marker_path, marker_content) return # During uninstall the old marker remains until all owned assets have # been removed, so an interrupted operation is never mistaken for a # completed removal. - filesystem.unlink(marker_path, missing_ok=True) + if marker_pending: + self._require_before_state(filesystem, marker_path, marker_before) + filesystem.unlink(marker_path) filesystem.remove_empty_parents(destination, filesystem.root) + def _path_state(self, filesystem: SafeProjectFS, path: Path) -> dict[str, Any]: + candidate = filesystem.validate(path) + try: + metadata = os.lstat(candidate) + except FileNotFoundError: + return {"kind": "missing"} + except OSError as error: + raise AnchorError(f"Cannot inspect managed skill asset: {candidate}") from error + if not stat.S_ISREG(metadata.st_mode): + raise AnchorError(f"Managed skill asset must be a regular file: {candidate}") + + identity = self._file_identity(metadata) + content = filesystem.read_bytes(candidate) + try: + after_read = os.lstat(candidate) + except OSError as error: + raise AnchorError(f"Managed skill asset changed while it was inspected: {candidate}") from error + if self._file_identity(after_read) != identity: + raise AnchorError(f"Managed skill asset changed while it was inspected: {candidate}") + return { + "kind": "file", + "sha256": self._digest(content), + "identity": identity, + } + + @staticmethod + def _file_identity(metadata: os.stat_result) -> dict[str, int]: + return { + "device": int(metadata.st_dev), + "inode": int(metadata.st_ino), + "mode": int(stat.S_IMODE(metadata.st_mode)), + "size": int(metadata.st_size), + "mtime_ns": int(metadata.st_mtime_ns), + "ctime_ns": int(metadata.st_ctime_ns), + } + + def _write_requires_apply( + self, + filesystem: SafeProjectFS, + target: Path, + content: bytes, + before: dict[str, Any], + ) -> bool: + current = self._path_state(filesystem, target) + if current.get("kind") == "file" and current.get("sha256") == self._digest(content): + return False + if current == before: + return True + self._raise_recovery_conflict(target) + + def _delete_requires_apply( + self, + filesystem: SafeProjectFS, + target: Path, + before: dict[str, Any], + ) -> bool: + current = self._path_state(filesystem, target) + if current == {"kind": "missing"}: + return False + if current == before: + return True + self._raise_recovery_conflict(target) + + def _require_before_state( + self, + filesystem: SafeProjectFS, + target: Path, + before: dict[str, Any], + ) -> None: + if self._path_state(filesystem, target) != before: + self._raise_recovery_conflict(target) + + @staticmethod + def _raise_recovery_conflict(target: Path) -> None: + raise AnchorError( + f"Refusing interrupted skill recovery because {target} changed after the operation " + "was journaled. The user-authored file was left unchanged; inspect it before " + "resolving the retained recovery journal." + ) + @staticmethod def _validate_platform(platform: str) -> None: if platform not in SUPPORTED_PLATFORMS: diff --git a/src/anchorloop/version.py b/src/anchorloop/version.py index e242732..d83f539 100644 --- a/src/anchorloop/version.py +++ b/src/anchorloop/version.py @@ -5,4 +5,4 @@ published. """ -VERSION = "0.1.0" +VERSION = "0.2.0" diff --git a/tests/test_actual_diff_risk.py b/tests/test_actual_diff_risk.py new file mode 100644 index 0000000..be844fd --- /dev/null +++ b/tests/test_actual_diff_risk.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import json +import re +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from anchorloop.project import AnchorError, AnchorProject + + +BRIEF = { + "outcome": "Deliver the bounded acceptance behavior.", + "scope": "Only the active implementation path.", + "constraints": "Keep the public interface stable.", + "invariant": "The accepted operation has no duplicate effect.", + "uncertainty": "The retry boundary may be incorrect.", +} + + +class ActualDiffRiskTests(unittest.TestCase): + def _git(self, root: Path, *arguments: str) -> None: + subprocess.run( + ["git", *arguments], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + + def _init_git(self, root: Path) -> None: + self._git(root, "init", "--quiet") + self._git(root, "config", "user.name", "AnchorLoop Tests") + self._git( + root, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + self._git(root, "config", "commit.gpgsign", "false") + + def _commit_file( + self, + root: Path, + relative: str, + content: str = "baseline\n", + ) -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + self._git(root, "add", "--", relative) + self._git( + root, + "commit", + "--quiet", + "-m", + f"baseline {relative}", + ) + return path + + def _plan( + self, + project: AnchorProject, + *, + mode: str, + override: str | None = None, + ) -> None: + project.plan_task( + "Persist the retry attempt before delivery.", + mode=mode, + task_type="feature", + approach="Write retry state before calling the destination.", + rejected_alternative=( + "An in-memory counter loses state after restart." + ), + primary_risk="A retry could acknowledge one event twice.", + verification_strategy=( + "Exercise duplicate and transient-failure scenarios." + ), + human_artifact=( + "Acceptance case: one delivery acknowledgement per event." + ), + comprehension=( + "Persisted state prevents duplicate acknowledgement." + ), + rollback_mitigation=( + "Restore the previous delivery state before accepting " + "another event." + if mode == "CAREFUL" + else None + ), + mode_override_reason=override, + by="Ada Engineer", + ) + + def _implementing_project( + self, + root: Path, + *, + mode: str = "STANDARD", + override: str | None = None, + ) -> AnchorProject: + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Protect the actual implementation diff") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode=mode, override=override) + project.approve_task("Ada Engineer") + project.transition("implement") + return project + + def test_standard_review_blocks_all_git_path_sources(self) -> None: + cases = ( + ("auth/permissions.py", "unstaged"), + ("migrations/0042.sql", "deleted"), + ("package-lock.json", "staged"), + ) + for relative, state in cases: + with ( + self.subTest(path=relative, state=state), + tempfile.TemporaryDirectory() as directory, + ): + root = Path(directory) + self._init_git(root) + path = self._commit_file(root, relative) + project = self._implementing_project(root) + + if state == "unstaged": + path.write_text("changed\n", encoding="utf-8") + elif state == "deleted": + path.unlink() + else: + path.write_text("changed\n", encoding="utf-8") + self._git(root, "add", "--", relative) + + before = path.read_bytes() if path.exists() else None + with self.assertRaisesRegex( + AnchorError, + re.escape(relative), + ) as raised: + project.transition("review") + self.assertIn( + "anchor revise --target plan --reason " + '"Actual diff introduced CAREFUL risk paths."', + str(raised.exception), + ) + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + self.assertNotIn("quality", task) + self.assertEqual( + path.read_bytes() if path.exists() else None, + before, + ) + + def test_careful_review_allows_risk_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + project = self._implementing_project(root, mode="CAREFUL") + for relative in ( + "auth/permissions.py", + "migrations/0042.sql", + "package-lock.json", + ): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("changed\n", encoding="utf-8") + + reviewed = project.transition("review") + self.assertEqual(reviewed["state"], "review_ready") + + def test_staged_rename_reports_the_sensitive_source_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "auth/permissions.py") + project = self._implementing_project(root) + self._git(root, "mv", "--", "auth/permissions.py", "harmless.py") + + with self.assertRaisesRegex( + AnchorError, + r"auth/permissions\.py", + ): + project.transition("review") + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + + def test_git_inspection_failure_blocks_review(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + project = self._implementing_project(root) + + with ( + mock.patch("anchorloop.quality._git_bytes", return_value=None), + self.assertRaisesRegex(AnchorError, "Cannot evaluate the actual Git diff safely"), + ): + project.transition("review") + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + + def test_non_git_project_blocks_lower_mode_review(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._implementing_project(root) + + with self.assertRaisesRegex( + AnchorError, + "Cannot evaluate the actual Git diff safely", + ): + project.transition("review") + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + + def test_nested_project_reports_paths_relative_to_project_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + self._init_git(repository) + root = repository / "nested-project" + self._commit_file(repository, "nested-project/auth/permissions.py") + project = self._implementing_project(root) + risky = root / "auth" / "permissions.py" + risky.write_text("changed\n", encoding="utf-8") + + with self.assertRaisesRegex( + AnchorError, + r"auth/permissions\.py", + ) as raised: + project.transition("review") + self.assertNotIn("nested-project/auth", str(raised.exception)) + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + + def test_preapproved_path_override_still_requires_a_revision_cycle(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + override = "Engineer reviewed actual diff risk path: auth/permissions.py" + project = self._implementing_project(root, override=override) + risky = root / "auth" / "permissions.py" + risky.parent.mkdir(parents=True) + risky.write_text("changed\n", encoding="utf-8") + + with self.assertRaisesRegex(AnchorError, r"auth/permissions\.py"): + project.transition("review") + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + + def test_lower_mode_explicit_override_is_approval_bound(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + project = self._implementing_project(root) + risky = root / "auth" / "permissions.py" + risky.parent.mkdir(parents=True) + risky.write_text("changed\n", encoding="utf-8") + + with self.assertRaises(AnchorError): + project.transition("review") + project.revise_task( + target="plan", + reason="Actual diff introduced CAREFUL risk paths.", + ) + override = ( + "Engineer reviewed actual diff risk path: " + "auth/permissions.py" + ) + self._plan( + project, + mode="STANDARD", + override=override, + ) + approved = project.approve_task("Ada Engineer") + self.assertEqual( + approved["plan"]["mode_recommendation"]["override_reason"], + override, + ) + self.assertEqual( + approved["approval"]["plan_digest"], + project._document_digest(approved["plan"]), + ) + + project.transition("implement") + reviewed = project.transition("review") + self.assertEqual(reviewed["state"], "review_ready") + + def test_anchor_only_changes_are_ignored(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + project = self._implementing_project(root) + ignored = root / ".anchor" / "auth" / "permissions.py" + ignored.parent.mkdir(parents=True) + ignored.write_text("managed state\n", encoding="utf-8") + + reviewed = project.transition("review") + self.assertEqual(reviewed["state"], "review_ready") + + def test_risk_added_after_review_blocks_precommit_without_quality( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + project = self._implementing_project(root) + reviewed = project.transition("review") + self.assertEqual(reviewed["state"], "review_ready") + + risky = root / "payments" / "new_gateway.py" + risky.parent.mkdir(parents=True) + risky.write_text("ENABLED = False\n", encoding="utf-8") + before = risky.read_bytes() + + with self.assertRaisesRegex( + AnchorError, + r"payments/new_gateway\.py", + ): + project.precommit() + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "review_ready") + self.assertNotIn("quality", task) + self.assertEqual(risky.read_bytes(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_filesystem_safety.py b/tests/test_filesystem_safety.py index 23d7890..9d3cda5 100644 --- a/tests/test_filesystem_safety.py +++ b/tests/test_filesystem_safety.py @@ -5,6 +5,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock from anchorloop.cli import main from anchorloop.project import AnchorError @@ -191,6 +192,62 @@ def test_project_skill_install_rejects_symlinked_managed_directories(self) -> No ) self.assertFalse((outside / "skills" / "anchorloop").exists()) + def test_global_skill_install_rejects_symlinked_managed_directories(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + for platform, managed_parts in ( + ("agents", (".agents",)), + ("codex", (".codex",)), + ("cursor", (".cursor",)), + ("gemini", (".gemini",)), + ("claude", (".claude",)), + ("opencode", (".config", "opencode")), + ): + project = base / f"project-global-{platform}" + home = base / f"home-{platform}" + outside = base / f"outside-global-{platform}" + project.mkdir() + home.mkdir() + outside.mkdir() + managed = home.joinpath(*managed_parts) + managed.parent.mkdir(parents=True, exist_ok=True) + self._symlink_or_skip(outside, managed, directory=True) + + with mock.patch("anchorloop.skill_install.Path.home", return_value=home): + self.assertEqual( + main( + [ + "install", + "--global", + "--platform", + platform, + "--apply", + "--force", + "--path", + str(project), + ] + ), + 2, + ) + self.assertFalse((outside / "skills" / "anchorloop").exists()) + + def test_windows_reparse_attribute_is_rejected_deterministically(self) -> None: + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + metadata = mock.Mock( + st_mode=stat.S_IFDIR | 0o755, + st_file_attributes=reparse_flag, + ) + with ( + mock.patch.object( + stat, + "FILE_ATTRIBUTE_REPARSE_POINT", + reparse_flag, + create=True, + ), + self.assertRaisesRegex(AnchorError, "symlink or reparse-point"), + ): + SafeProjectFS._reject_link(Path("managed"), metadata) + def test_installer_temp_and_marker_symlinks_are_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: base = Path(directory) diff --git a/tests/test_skill_install_matrix.py b/tests/test_skill_install_matrix.py new file mode 100644 index 0000000..d963d64 --- /dev/null +++ b/tests/test_skill_install_matrix.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from anchorloop.cli import main +from anchorloop.safe_fs import AnchorError, SafeProjectFS +from anchorloop.skill_install import SKILL_RUNTIME_NPX, SkillInstaller + + +PLATFORM_PARTS = { + "agents": ((".agents",), (".agents",)), + "codex": ((".codex",), (".codex",)), + "cursor": ((".cursor",), (".cursor",)), + "gemini": ((".gemini",), (".gemini",)), + "claude": ((".claude",), (".claude",)), + "opencode": ((".opencode",), (".config", "opencode")), +} +PINNED_PACKAGE = "anchorloop@9.8.7" +FORBIDDEN_STATE = ( + ".anchor", + "node_modules", + "cache", + ".cache", + ".npm", + ".npm-cache", + "__pycache__", +) + + +class SkillInstallMatrixTests(unittest.TestCase): + def test_project_and_global_platform_matrix(self) -> None: + for platform, (project_parts, global_parts) in PLATFORM_PARTS.items(): + for project_scoped, location in ((True, project_parts), (False, global_parts)): + with self.subTest(platform=platform, scope="project" if project_scoped else "global"): + self._exercise_installation( + platform=platform, + project_scoped=project_scoped, + location=location, + ) + + def _exercise_installation( + self, + *, + platform: str, + project_scoped: bool, + location: tuple[str, ...], + ) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + project = base / "project" + home = base / "home" + project.mkdir() + expected_root = project if project_scoped else home + expected = expected_root.joinpath(*location, "skills", "anchorloop") + + with mock.patch("anchorloop.skill_install.Path.home", return_value=home): + installer = SkillInstaller(project) + preview = installer.preview_install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + ) + self.assertEqual(preview.destination, expected) + self.assertIn(str(expected), "\n".join(preview.lines())) + + original_write = SkillInstaller._write_bytes + writes = 0 + + def interrupted_write( + filesystem: SafeProjectFS, + path: Path, + content: bytes, + ) -> None: + nonlocal writes + writes += 1 + original_write(filesystem, path, content) + if writes == 1: + raise RuntimeError("matrix install interruption") + + with mock.patch.object(SkillInstaller, "_write_bytes", side_effect=interrupted_write): + with self.assertRaisesRegex(RuntimeError, "matrix install interruption"): + installer.install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + ) + + with self.assertRaisesRegex(AnchorError, "Recovered interrupted skill install"): + installer.install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + ) + installer.install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + ) + owned_files = { + path.relative_to(expected).as_posix() + for path in expected.rglob("*") + if path.is_file() + } + self.assertEqual( + owned_files, + {"SKILL.md", "references/workflow.md", ".anchorloop-skill.json"}, + ) + + marker_path = expected / ".anchorloop-skill.json" + marker = json.loads(marker_path.read_text(encoding="utf-8")) + self.assertEqual(marker["platform"], platform) + self.assertEqual(marker["scope"], "project" if project_scoped else "user-global") + self.assertEqual(marker["runtime"], "npx") + self.assertEqual(marker["npx_package"], PINNED_PACKAGE) + self.assertEqual( + {entry["path"] for entry in marker["files"]}, + {"SKILL.md", "references/workflow.md"}, + ) + for entry in marker["files"]: + content = (expected / entry["path"]).read_bytes() + self.assertEqual( + entry["sha256"], + f"sha256:{hashlib.sha256(content).hexdigest()}", + ) + self.assertIn( + f"npx --yes {PINNED_PACKAGE} status", + (expected / "SKILL.md").read_text(encoding="utf-8"), + ) + + user_file = expected / "user-note.txt" + user_file.write_text("preserve me\n", encoding="utf-8") + installer.install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + ) + self.assertEqual(user_file.read_text(encoding="utf-8"), "preserve me\n") + + skill = expected / "SKILL.md" + skill.write_text(skill.read_text(encoding="utf-8") + "\nlocal edit\n", encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "Refusing to update modified"): + installer.install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + ) + self.assertIn("local edit", skill.read_text(encoding="utf-8")) + + installer.install( + platform=platform, + project_scoped=project_scoped, + runtime=SKILL_RUNTIME_NPX, + npx_package=PINNED_PACKAGE, + force=True, + ) + installer.uninstall(platform=platform, project_scoped=project_scoped) + self.assertTrue(user_file.is_file()) + self.assertFalse(marker_path.exists()) + self.assertFalse(skill.exists()) + self.assertFalse((expected / "references" / "workflow.md").exists()) + + for root in (project, home): + for relative_name in FORBIDDEN_STATE: + self.assertFalse((root / relative_name).exists(), f"{root / relative_name} leaked") + + def test_global_all_uses_only_native_destinations(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + home = Path(directory) / "home" + root.mkdir() + native_locations = ( + (".codex",), + (".cursor",), + (".gemini",), + (".claude",), + (".config", "opencode"), + ) + with mock.patch("anchorloop.skill_install.Path.home", return_value=home): + self.assertEqual( + main(["install", "--global", "--all", "--apply", "--path", str(root)]), + 0, + ) + for location in native_locations: + self.assertTrue(home.joinpath(*location, "skills", "anchorloop", "SKILL.md").is_file()) + self.assertFalse((home / ".agents" / "skills" / "anchorloop").exists()) + self.assertFalse((root / ".anchor").exists()) + + self.assertEqual( + main(["uninstall", "--global", "--all", "--apply", "--path", str(root)]), + 0, + ) + for location in native_locations: + self.assertFalse(home.joinpath(*location, "skills", "anchorloop").exists()) + self.assertFalse((home / ".agents").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skill_install_recovery.py b/tests/test_skill_install_recovery.py index 224f01b..36dc55b 100644 --- a/tests/test_skill_install_recovery.py +++ b/tests/test_skill_install_recovery.py @@ -1,11 +1,14 @@ from __future__ import annotations +import io import json import tempfile import unittest +from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from unittest import mock +from anchorloop.cli import main from anchorloop.safe_fs import AnchorError, SafeProjectFS from anchorloop.skill_install import SKILL_RUNTIME_ANCHOR, SkillInstaller @@ -19,16 +22,26 @@ def test_pending_journal_paths_are_validated_before_recovery_mutates_destination journal_filesystem, journal_path = installer._journal_context(destination) escaped = root / "outside.txt" journal = { - "schema_version": 1, + "schema_version": 2, "destination": str(destination), "action": "install", "platform": "agents", "project_scoped": True, "version": "0.1.0", "runtime": "anchor", - "writes": [installer._encoded_file("../../../../outside.txt", b"escape")], + "writes": [ + installer._encoded_file( + "../../../../outside.txt", + b"escape", + before={"kind": "missing"}, + ) + ], "deletes": [], - "marker": installer._encoded_file(".anchorloop-skill.json", b"{}"), + "marker": installer._encoded_file( + ".anchorloop-skill.json", + b"{}", + before={"kind": "missing"}, + ), } journal_filesystem.atomic_write_text(journal_path, json.dumps(journal) + "\n") try: @@ -40,8 +53,8 @@ def test_pending_journal_paths_are_validated_before_recovery_mutates_destination installer.install(platform="agents", project_scoped=True) self.assertFalse(escaped.exists()) self.assertFalse(destination.exists()) - finally: - journal_filesystem.unlink(journal_path, missing_ok=True) + finally: + journal_filesystem.unlink(journal_path, missing_ok=True) def test_interrupted_initial_install_is_reported_without_mutation_then_rolls_forward(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -76,8 +89,10 @@ def interrupted_write(filesystem: SafeProjectFS, path: Path, content: bytes) -> self.assertEqual((destination / "SKILL.md").read_bytes(), before) self.assertTrue(journal_path.is_file(), "status must not recover or delete the journal") - installation = installer.install(platform="agents", project_scoped=True) - self.assertEqual(installation.destination, destination) + with mock.patch.object(installer, "_commit_install_journal") as commit: + with self.assertRaisesRegex(AnchorError, "Recovered interrupted skill install"): + installer.install(platform="agents", project_scoped=True) + commit.assert_not_called() self.assertTrue(marker.is_file()) self.assertFalse(journal_path.exists()) final_status = installer.installation_status(platform="agents", project_scoped=True) @@ -121,7 +136,10 @@ def interrupted_write(filesystem: SafeProjectFS, path: Path, content: bytes) -> self.assertTrue(pending["recovery_pending"]) self.assertEqual(pending["recovery_action"], "install") - installer.install(platform="codex", project_scoped=True) + with mock.patch.object(installer, "_commit_install_journal") as commit: + with self.assertRaisesRegex(AnchorError, "Recovered interrupted skill install"): + installer.install(platform="codex", project_scoped=True) + commit.assert_not_called() current = installer.installation_status(platform="codex", project_scoped=True) self.assertTrue(current["up_to_date"]) @@ -172,8 +190,8 @@ def interrupted_unlink( self.assertTrue(pending["recovery_pending"]) self.assertEqual(pending["recovery_action"], "uninstall") - recovered = installer.uninstall(platform="agents", project_scoped=True) - self.assertEqual(recovered.destination, destination) + with self.assertRaisesRegex(AnchorError, "Recovered interrupted skill uninstall"): + installer.uninstall(platform="agents", project_scoped=True) self.assertTrue(note.is_file()) self.assertFalse((destination / "SKILL.md").exists()) self.assertFalse((destination / ".anchorloop-skill.json").exists()) @@ -183,6 +201,286 @@ def interrupted_unlink( _, journal_path = installer._journal_context(destination) self.assertFalse(journal_path.exists()) + def test_recovery_stops_an_opposite_requested_mutation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + original_write = SkillInstaller._write_bytes + writes = 0 + + def interrupted_write(filesystem: SafeProjectFS, path: Path, content: bytes) -> None: + nonlocal writes + writes += 1 + original_write(filesystem, path, content) + if writes == 1: + raise RuntimeError("simulated install interruption") + + with mock.patch.object(SkillInstaller, "_write_bytes", side_effect=interrupted_write): + with self.assertRaisesRegex(RuntimeError, "simulated install interruption"): + installer.install(platform="agents", project_scoped=True) + + with self.assertRaisesRegex(AnchorError, "No new uninstall was started"): + installer.uninstall(platform="agents", project_scoped=True) + self.assertTrue((destination / ".anchorloop-skill.json").is_file()) + self.assertTrue((destination / "SKILL.md").is_file()) + _, journal_path = installer._journal_context(destination) + self.assertFalse(journal_path.exists()) + + def test_recovery_after_marker_write_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + original_write = SkillInstaller._write_bytes + + def interrupt_after_marker( + filesystem: SafeProjectFS, + path: Path, + content: bytes, + ) -> None: + original_write(filesystem, path, content) + if path.name == ".anchorloop-skill.json": + raise RuntimeError("simulated marker interruption") + + with mock.patch.object(SkillInstaller, "_write_bytes", side_effect=interrupt_after_marker): + with self.assertRaisesRegex(RuntimeError, "simulated marker interruption"): + installer.install(platform="agents", project_scoped=True) + + marker = destination / ".anchorloop-skill.json" + self.assertTrue(marker.is_file()) + with mock.patch.object(SkillInstaller, "_write_bytes") as write: + with self.assertRaisesRegex(AnchorError, "Recovered interrupted skill install"): + installer.install(platform="agents", project_scoped=True) + write.assert_not_called() + _, journal_path = installer._journal_context(destination) + self.assertFalse(journal_path.exists()) + + def test_recovery_refuses_to_overwrite_a_post_crash_user_edit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + installer.install(platform="agents", project_scoped=True) + + updated_assets = [] + for path, content in SkillInstaller._asset_files( + runtime=SKILL_RUNTIME_ANCHOR, + npx_package=None, + ): + if path.as_posix() == "SKILL.md": + content += b"\nInterrupted update bundle.\n" + updated_assets.append((path, content)) + + original_write = SkillInstaller._write_bytes + writes = 0 + + def interrupted_write(filesystem: SafeProjectFS, path: Path, content: bytes) -> None: + nonlocal writes + writes += 1 + original_write(filesystem, path, content) + if writes == 1: + raise RuntimeError("simulated update interruption") + + with mock.patch.object(SkillInstaller, "_asset_files", return_value=updated_assets): + with mock.patch.object(SkillInstaller, "_write_bytes", side_effect=interrupted_write): + with self.assertRaisesRegex(RuntimeError, "simulated update interruption"): + installer.install(platform="agents", project_scoped=True) + + skill = destination / "SKILL.md" + skill.write_text("post-crash user edit\n", encoding="utf-8") + journal_filesystem, journal_path = installer._journal_context(destination) + try: + with self.assertRaisesRegex(AnchorError, "changed after the operation was journaled"): + installer.install(platform="agents", project_scoped=True) + self.assertEqual(skill.read_text(encoding="utf-8"), "post-crash user edit\n") + self.assertTrue(journal_path.is_file()) + finally: + journal_filesystem.unlink(journal_path, missing_ok=True) + + def test_recovery_refuses_to_delete_a_post_crash_recreated_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + installer.install(platform="agents", project_scoped=True) + + original_unlink = SafeProjectFS.unlink + removed: Path | None = None + removed_content: bytes | None = None + + def interrupted_unlink( + filesystem: SafeProjectFS, + path: Path, + *, + missing_ok: bool = False, + ) -> None: + nonlocal removed, removed_content + candidate = Path(path) + if removed is None and destination in candidate.parents and candidate.name != ".anchorloop-skill.json": + removed_content = filesystem.read_bytes(candidate) + original_unlink(filesystem, candidate, missing_ok=missing_ok) + removed = candidate + raise RuntimeError("simulated uninstall interruption") + original_unlink(filesystem, candidate, missing_ok=missing_ok) + + with mock.patch.object(SafeProjectFS, "unlink", new=interrupted_unlink): + with self.assertRaisesRegex(RuntimeError, "simulated uninstall interruption"): + installer.uninstall(platform="agents", project_scoped=True) + + self.assertIsNotNone(removed) + self.assertIsNotNone(removed_content) + assert removed is not None + assert removed_content is not None + removed.parent.mkdir(parents=True, exist_ok=True) + removed.write_bytes(removed_content) + journal_filesystem, journal_path = installer._journal_context(destination) + try: + with self.assertRaisesRegex(AnchorError, "changed after the operation was journaled"): + installer.uninstall(platform="agents", project_scoped=True) + self.assertEqual(removed.read_bytes(), removed_content) + self.assertTrue(journal_path.is_file()) + finally: + journal_filesystem.unlink(journal_path, missing_ok=True) + + def test_global_all_install_recovers_before_any_fresh_destination_mutation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + home = root / "home" + home.mkdir() + installer = SkillInstaller(root) + original_write = SkillInstaller._write_bytes + writes = 0 + + def interrupted_write( + filesystem: SafeProjectFS, + path: Path, + content: bytes, + ) -> None: + nonlocal writes + writes += 1 + original_write(filesystem, path, content) + if writes == 1: + raise RuntimeError("simulated global install interruption") + + with mock.patch("anchorloop.skill_install.Path.home", return_value=home): + with mock.patch.object( + SkillInstaller, + "_write_bytes", + side_effect=interrupted_write, + ): + with self.assertRaisesRegex( + RuntimeError, + "simulated global install interruption", + ): + installer.install( + platform="gemini", + project_scoped=False, + ) + + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + result = main( + [ + "install", + "--global", + "--all", + "--apply", + "--path", + str(root), + ] + ) + self.assertEqual(result, 2) + gemini = installer.destination_for( + platform="gemini", + project_scoped=False, + ) + self.assertTrue((gemini / ".anchorloop-skill.json").is_file()) + for platform in ("codex", "cursor", "claude", "opencode"): + destination = installer.destination_for( + platform=platform, + project_scoped=False, + ) + self.assertFalse(destination.exists(), platform) + + def test_global_all_uninstall_recovers_before_any_fresh_destination_mutation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + home = root / "home" + home.mkdir() + installer = SkillInstaller(root) + platforms = ("codex", "cursor", "gemini", "claude", "opencode") + + with mock.patch("anchorloop.skill_install.Path.home", return_value=home): + for platform in platforms: + installer.install(platform=platform, project_scoped=False) + + gemini = installer.destination_for( + platform="gemini", + project_scoped=False, + ) + original_unlink = SafeProjectFS.unlink + removed = False + + def interrupted_unlink( + filesystem: SafeProjectFS, + path: Path, + *, + missing_ok: bool = False, + ) -> None: + nonlocal removed + candidate = Path(path) + if ( + not removed + and gemini in candidate.parents + and candidate.name != ".anchorloop-skill.json" + ): + original_unlink( + filesystem, + candidate, + missing_ok=missing_ok, + ) + removed = True + raise RuntimeError("simulated global uninstall interruption") + original_unlink(filesystem, candidate, missing_ok=missing_ok) + + with mock.patch.object( + SafeProjectFS, + "unlink", + new=interrupted_unlink, + ): + with self.assertRaisesRegex( + RuntimeError, + "simulated global uninstall interruption", + ): + installer.uninstall( + platform="gemini", + project_scoped=False, + ) + + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + result = main( + [ + "uninstall", + "--global", + "--all", + "--apply", + "--path", + str(root), + ] + ) + self.assertEqual(result, 2) + self.assertFalse((gemini / ".anchorloop-skill.json").exists()) + self.assertFalse((gemini / "SKILL.md").exists()) + for platform in ("codex", "cursor", "claude", "opencode"): + destination = installer.destination_for( + platform=platform, + project_scoped=False, + ) + self.assertTrue( + (destination / ".anchorloop-skill.json").is_file(), + platform, + ) + if __name__ == "__main__": unittest.main() From 25f2afada3d504300255b2aeb05280b5cee2918c Mon Sep 17 00:00:00 2001 From: ppmarkek <153396079+ppmarkek@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:59:51 +0300 Subject: [PATCH 2/5] Refine anchor loop navigation and UI flows --- MANIFEST.in | 1 + SECURITY.md | 4 +- docs/assets/anchorloop-delivery-loop.svg | 187 +++--- docs/assets/anchorloop-evidence-integrity.svg | 165 +++--- npm/bin/anchorloop.js | 4 +- npm/scripts/registry-smoke.js | 158 +++++ npm/scripts/version.js | 16 +- npm/test/anchorloop.test.js | 66 +++ src/anchorloop/project.py | 551 ++++++++++++++++-- src/anchorloop/skill_install.py | 132 ++++- tests/git_fixture.py | 17 + tests/test_cli.py | 11 +- tests/test_core_validation.py | 155 +++++ tests/test_human_ownership.py | 66 +++ tests/test_integrity_regressions.py | 2 + tests/test_skill_install_matrix.py | 82 +++ tests/test_skill_install_recovery.py | 46 +- tests/test_transaction.py | 2 + 18 files changed, 1406 insertions(+), 259 deletions(-) create mode 100644 MANIFEST.in create mode 100644 tests/git_fixture.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6cf4749 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include tests/git_fixture.py diff --git a/SECURITY.md b/SECURITY.md index 5b8a2cf..7c5f3e4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported versions -AnchorLoop 0.1 is a public-alpha release candidate. Security fixes are applied to the current development -version; older snapshots are not maintained. +The published baseline is `anchorloop@0.1.0` (public alpha). Version `0.2.0` is an unreleased release +candidate. Security fixes are applied to the current development version; older snapshots are not maintained. ## Reporting a vulnerability diff --git a/docs/assets/anchorloop-delivery-loop.svg b/docs/assets/anchorloop-delivery-loop.svg index b447d32..5f7459b 100644 --- a/docs/assets/anchorloop-delivery-loop.svg +++ b/docs/assets/anchorloop-delivery-loop.svg @@ -1,118 +1,113 @@ - + AnchorLoop delivery loop - A workflow from task through brief, plan, engineer approval, implementation, review, quality evidence, verification, and close. Failed verification moves through an explicit revision back to planning or implementation. + A numbered workflow from task through brief, plan, engineer approval, implementation, review and precommit, verification, and close. A failed verification remains recorded and returns explicitly to implementation or to planning and approval. - - - - - - - - - - - - - - - - - + + - - + + - - The delivery loop stays engineer-controlled - Implementation begins only after an approved brief and plan. Failed verification takes an explicit route back. + - - - - - - - - - - - RECORD - PLAN - APPROVE - BUILD - REVIEW - VERIFY + Delivery stays engineer-controlled + Implementation starts only after approval. Every later gate records evidence before work can close. + + + - - - - Task - Outcome and scope - are explicit + + + + 01 + Task + Outcome and scope + set by the engineer - - - Brief - Constraints + - acceptance case + + + 02 + Brief + Constraints and + acceptance case - - - Plan - Concrete approach - for review + + + 03 + Plan + Approach, trade-off, + and verification - - - Engineer approval - Pins brief, plan - and ruleset - before execution + + + 04 + Engineer approval + Pins brief, plan, + and ruleset - - - Implement - Agent writes the - approved change + + + 05 + Implement + Agent writes the + approved change - - - Review + precommit - Checks + workspace - fingerprint become - recorded evidence + + + 06 + Review + precommit + Checks and fingerprint + become evidence - - - Verify - Engineer records - observed result + + + 07 + Verify + Engineer records the + observed result - - - Close + + + 08 + Close + Evidence still matches; + the task can close - - - - - - Verification fails → revise - Return to implementation when scope holds; return to planning when it changed. + + + + ! + Verification fails + The failed result stays recorded. + + SAME SCOPE + Return to implementation + SCOPE OR SOLUTION CHANGED + Revise plan, then re-approve - AnchorLoop records the loop; it does not claim to authenticate the person providing an approval. + AnchorLoop records workflow evidence; it does not authenticate the person providing approval. diff --git a/docs/assets/anchorloop-evidence-integrity.svg b/docs/assets/anchorloop-evidence-integrity.svg index 7d5b469..80ec892 100644 --- a/docs/assets/anchorloop-evidence-integrity.svg +++ b/docs/assets/anchorloop-evidence-integrity.svg @@ -1,103 +1,102 @@ - + AnchorLoop evidence integrity - Approved brief, plan, and ruleset create an approval record with digests. Precommit checks create a workspace fingerprint. Changes to approved artifacts archive the approval, while changes to the workspace require review and precommit again before verification or close. + Approved brief, plan, and ruleset are pinned in an approval record. Precommit checks and a workspace fingerprint create quality evidence. Verification and close require both records to remain current; changed inputs archive approval, and changed workspace content requires review and precommit again. - - - - - - - - - - - - - - - - - - - - + + - - Evidence is tied to exactly what was approved and checked - Every later gate depends on recorded inputs remaining unchanged. + - - - Engineer brief - Outcome · scope - constraints + Evidence expires when reality changes + Verify and close stay available only while the approved inputs and checked workspace still match. + - - Plan - Concrete approach - for approval + - - Ruleset snapshot - Approved quality, - security, structure + + + APPROVED INPUTS + - - Approval record - • brief digest - • plan digest - • ruleset digest - Explicit approval permits implementation + Engineer brief + Outcome, scope, constraints + - - Precommit evidence - Syntax · whitespace · active security scan - Only enabled, approved rules apply + Plan + Approach and verification + - - Workspace fingerprint - SHA-256 over materialized tracked - and non-ignored untracked content - Submodule HEAD + dirty status are included + Ruleset snapshot + Quality, security, structure + + + Pinned by digest - - Verify / close - Allowed only when - approval and quality - evidence still match - + + 01 APPROVAL RECORD + What the engineer approved + Brief digest · Plan digest · Ruleset digest + Explicit approval permits implementation. - - - - - - - - - + + 02 QUALITY EVIDENCE + What was actually checked + - - - + Precommit checks + Syntax + whitespace + Approved security rules + + Workspace fingerprint + SHA-256 over checked + workspace + submodules + + + FINAL GATE + + + Verify / close + Allowed only while + approval and quality + evidence still match. + + NO STALE EVIDENCE - - Brief, plan or rules change - Archive stale approval → re-approve - - Workspace changes after checks - Return to review_ready → rerun precommit + + + + ! + APPROVED INPUT CHANGED + Archive approval → revise and re-approve + + + + ! + WORKSPACE CHANGED AFTER CHECKS + Return to review → rerun precommit + - These records provide auditability, not proof of a person's identity or access-control enforcement. + Audit evidence, not proof of identity or access control. diff --git a/npm/bin/anchorloop.js b/npm/bin/anchorloop.js index cf39cb5..538b566 100644 --- a/npm/bin/anchorloop.js +++ b/npm/bin/anchorloop.js @@ -4,7 +4,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); -const { assertVersionConsistency } = require("../scripts/version.js"); +const { assertBundledVersionConsistency } = require("../scripts/version.js"); const packageRoot = path.resolve(__dirname, "..", ".."); const sourceRoot = path.join(packageRoot, "src"); @@ -18,7 +18,7 @@ const isolatedPythonEntry = [ ].join("; "); function bundledRelease() { - return assertVersionConsistency({ root: packageRoot }); + return assertBundledVersionConsistency({ root: packageRoot }); } function printUsage() { diff --git a/npm/scripts/registry-smoke.js b/npm/scripts/registry-smoke.js index bd779ac..a22b9ed 100644 --- a/npm/scripts/registry-smoke.js +++ b/npm/scripts/registry-smoke.js @@ -2,6 +2,7 @@ "use strict"; const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -10,6 +11,27 @@ const { assertPackageName, assertVersionConsistency } = require("./version.js"); const repositoryRoot = path.resolve(__dirname, "..", ".."); +const GLOBAL_PLATFORM_PARTS = Object.freeze({ + agents: [".agents"], + codex: [".codex"], + cursor: [".cursor"], + gemini: [".gemini"], + claude: [".claude"], + opencode: [".config", "opencode"], +}); + +function buildGlobalPlatformPlan(workspace) { + return Object.entries(GLOBAL_PLATFORM_PARTS).map(([platform, destinationParts]) => { + const home = path.join(workspace, `home-${platform}`); + return { + platform, + home, + project: path.join(workspace, `project-${platform}`), + destination: path.join(home, ...destinationParts, "skills", "anchorloop"), + }; + }); +} + function usage() { console.log(`Usage: node npm/scripts/registry-smoke.js [--package ] [--version ] @@ -84,6 +106,139 @@ function waitForRegistry(npm, packageSpec, environment) { throw new Error(`${packageSpec} did not become readable from the npm registry within two minutes.`); } +function listFiles(root) { + const files = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(absolute); + } else { + files.push(path.relative(root, absolute).split(path.sep).join("/")); + } + } + }; + visit(root); + return files.sort(); +} + +function fileDigest(file) { + return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; +} + +function assertNoProjectResidue(project, platform) { + for (const forbidden of [ + ".anchor", + ".agents", + ".codex", + ".cursor", + ".gemini", + ".claude", + ".opencode", + "node_modules", + "cache", + ".cache", + ".npm", + ".npm-cache", + "__pycache__", + ]) { + assert.equal( + fs.existsSync(path.join(project, forbidden)), + false, + `${platform} global install leaked ${forbidden} into its project`, + ); + } +} + +function assertNoOtherGlobalDestinations(home, selectedPlatform) { + for (const [platform, destinationParts] of Object.entries(GLOBAL_PLATFORM_PARTS)) { + if (platform === selectedPlatform) { + continue; + } + const hostRoot = path.join(home, ...destinationParts); + assert.equal( + fs.existsSync(hostRoot), + false, + `${selectedPlatform} install unexpectedly created the ${platform} host directory`, + ); + } +} + +function runGlobalPlatformMatrix({ packageSpec, workspace, npx, environment }) { + const expectedVersion = packageSpec.slice(packageSpec.lastIndexOf("@") + 1); + for (const entry of buildGlobalPlatformPlan(workspace)) { + fs.mkdirSync(entry.home, { recursive: true }); + fs.mkdirSync(entry.project, { recursive: true }); + const platformEnvironment = { + ...environment, + HOME: entry.home, + USERPROFILE: entry.home, + }; + const anchor = (...args) => run( + npx, + ["--yes", packageSpec, ...args], + { cwd: entry.project, env: platformEnvironment }, + ); + const installArguments = [ + "install", + "--global", + "--platform", entry.platform, + "--apply", + ]; + + anchor(...installArguments); + const markerPath = path.join(entry.destination, ".anchorloop-skill.json"); + const skillPath = path.join(entry.destination, "SKILL.md"); + const workflowPath = path.join(entry.destination, "references", "workflow.md"); + assert.deepEqual( + listFiles(entry.destination), + [".anchorloop-skill.json", "SKILL.md", "references/workflow.md"], + `${entry.platform} install created files outside the owned bundle`, + ); + + const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); + assert.equal(marker.skill, "anchorloop"); + assert.equal(marker.schema_version, 2); + assert.equal(marker.platform, entry.platform); + assert.equal(marker.scope, "user-global"); + assert.equal(marker.version, expectedVersion); + assert.equal(marker.runtime, "npx"); + assert.equal(marker.npx_package, packageSpec); + assert.deepEqual( + marker.files, + [ + { path: "SKILL.md", sha256: fileDigest(skillPath) }, + { path: "references/workflow.md", sha256: fileDigest(workflowPath) }, + ], + `${entry.platform} marker does not describe the exact owned bundle`, + ); + const escapedPackageSpec = packageSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + assert.match(fs.readFileSync(skillPath, "utf8"), new RegExp(`npx --yes ${escapedPackageSpec} status`)); + assert.match(fs.readFileSync(workflowPath, "utf8"), new RegExp(`npx --yes ${escapedPackageSpec} add --apply`)); + assertNoOtherGlobalDestinations(entry.home, entry.platform); + assertNoProjectResidue(entry.project, entry.platform); + + const userFile = path.join(entry.destination, "user-note.txt"); + fs.writeFileSync(userFile, "preserve me\n", "utf8"); + anchor(...installArguments); + assert.equal(fs.readFileSync(userFile, "utf8"), "preserve me\n"); + + anchor( + "uninstall", + "--global", + "--platform", entry.platform, + "--apply", + ); + assert.equal(fs.existsSync(skillPath), false, `${entry.platform} uninstall retained SKILL.md`); + assert.equal(fs.existsSync(workflowPath), false, `${entry.platform} uninstall retained workflow.md`); + assert.equal(fs.existsSync(markerPath), false, `${entry.platform} uninstall retained its marker`); + assert.equal(fs.readFileSync(userFile, "utf8"), "preserve me\n"); + assertNoOtherGlobalDestinations(entry.home, entry.platform); + assertNoProjectResidue(entry.project, entry.platform); + } + console.log(`${packageSpec}: six-platform global install matrix passed.`); +} + function runSmoke({ packageName, version }) { const packageSpec = `${packageName}@${version}`; const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-registry-smoke-")); @@ -201,6 +356,7 @@ function runSmoke({ packageName, version }) { assert.equal(fs.existsSync(directory), true, `${relative} is missing after the lifecycle`); assert.deepEqual(fs.readdirSync(directory), [], `${relative} retained recovery residue`); } + runGlobalPlatformMatrix({ packageSpec, workspace, npx, environment }); console.log(`${packageSpec}: clean registry lifecycle passed.`); } finally { fs.rmSync(workspace, { recursive: true, force: true }); @@ -227,6 +383,8 @@ if (require.main === module) { } module.exports = { + buildGlobalPlatformPlan, parseArguments, + runGlobalPlatformMatrix, runSmoke, }; diff --git a/npm/scripts/version.js b/npm/scripts/version.js index 4747f03..b396d7f 100644 --- a/npm/scripts/version.js +++ b/npm/scripts/version.js @@ -34,11 +34,8 @@ function readCanonicalVersion(root = repositoryRoot) { return match[1]; } -function assertVersionConsistency(options = {}) { +function assertBundledVersionConsistency(options = {}) { const root = options.root || repositoryRoot; - const releaseTag = options.releaseTag === undefined - ? process.env.ANCHORLOOP_RELEASE_TAG - : options.releaseTag; const version = readCanonicalVersion(root); const packagePath = path.join(root, "package.json"); const packageMetadata = JSON.parse(fs.readFileSync(packagePath, "utf8")); @@ -53,6 +50,16 @@ function assertVersionConsistency(options = {}) { throw new Error("AnchorLoop must not depend on itself."); } + return { packageName, version }; +} + +function assertVersionConsistency(options = {}) { + const root = options.root || repositoryRoot; + const releaseTag = options.releaseTag === undefined + ? process.env.ANCHORLOOP_RELEASE_TAG + : options.releaseTag; + const { packageName, version } = assertBundledVersionConsistency({ root }); + const lockPath = path.join(root, "package-lock.json"); const packageLock = JSON.parse(fs.readFileSync(lockPath, "utf8")); if ( @@ -113,6 +120,7 @@ if (require.main === module) { } module.exports = { + assertBundledVersionConsistency, assertPackageName, assertVersionConsistency, readCanonicalVersion, diff --git a/npm/test/anchorloop.test.js b/npm/test/anchorloop.test.js index 1b83196..0d0b802 100644 --- a/npm/test/anchorloop.test.js +++ b/npm/test/anchorloop.test.js @@ -11,6 +11,7 @@ const repositoryRoot = path.resolve(__dirname, "..", ".."); const launcher = path.join(repositoryRoot, "npm", "bin", "anchorloop.js"); const packageMetadata = require(path.join(repositoryRoot, "package.json")); const { readCanonicalVersion } = require(path.join(repositoryRoot, "npm", "scripts", "version.js")); +const { buildGlobalPlatformPlan } = require(path.join(repositoryRoot, "npm", "scripts", "registry-smoke.js")); const { parseShortcutOptions, shouldOpenGuidedInstaller } = require(launcher); function runLauncher(args, cwd) { @@ -22,6 +23,38 @@ function runLauncher(args, cwd) { }); } +test("registry smoke isolates all six global platform destinations", () => { + const workspace = path.join("release-smoke", "workspace"); + const plan = buildGlobalPlatformPlan(workspace); + + assert.deepEqual(plan.map(({ platform }) => platform), [ + "agents", + "codex", + "cursor", + "gemini", + "claude", + "opencode", + ]); + assert.deepEqual( + plan.map(({ platform, home, project, destination }) => ({ + platform, + home: path.relative(workspace, home).split(path.sep).join("/"), + project: path.relative(workspace, project).split(path.sep).join("/"), + destination: path.relative(home, destination).split(path.sep).join("/"), + })), + [ + { platform: "agents", home: "home-agents", project: "project-agents", destination: ".agents/skills/anchorloop" }, + { platform: "codex", home: "home-codex", project: "project-codex", destination: ".codex/skills/anchorloop" }, + { platform: "cursor", home: "home-cursor", project: "project-cursor", destination: ".cursor/skills/anchorloop" }, + { platform: "gemini", home: "home-gemini", project: "project-gemini", destination: ".gemini/skills/anchorloop" }, + { platform: "claude", home: "home-claude", project: "project-claude", destination: ".claude/skills/anchorloop" }, + { platform: "opencode", home: "home-opencode", project: "project-opencode", destination: ".config/opencode/skills/anchorloop" }, + ], + ); + assert.equal(new Set(plan.map(({ home }) => home)).size, plan.length); + assert.equal(new Set(plan.map(({ project }) => project)).size, plan.length); +}); + test("the shortcut installs a Codex skill that keeps an npx command runner", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-npx-")); const project = path.join(temporaryRoot, "O'Reilly project"); @@ -236,6 +269,39 @@ test("the actual npm tarball contains only release runtime and documentation fil ]) { assert(entries.includes(required), `missing release archive entry: ${required}`); } + assert.equal( + entries.includes("package/package-lock.json"), + false, + "published runtime must not depend on checkout-only package-lock.json", + ); + + const extractRoot = path.join(temporaryRoot, "extracted"); + fs.mkdirSync(extractRoot); + const extract = spawnSync("tar", ["-xzf", archive, "-C", extractRoot], { + cwd: temporaryRoot, + encoding: "utf8", + shell: false, + windowsHide: true, + }); + assert.equal(extract.status, 0, extract.stderr); + + const packagedRoot = path.join(extractRoot, "package"); + const packagedVersion = spawnSync( + process.execPath, + [path.join(packagedRoot, "npm", "bin", "anchorloop.js"), "--version"], + { + cwd: packagedRoot, + encoding: "utf8", + shell: false, + windowsHide: true, + }, + ); + assert.equal( + packagedVersion.status, + 0, + `${packagedVersion.stdout}\n${packagedVersion.stderr}`, + ); + assert.equal(packagedVersion.stdout.trim(), packageMetadata.version); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } diff --git a/src/anchorloop/project.py b/src/anchorloop/project.py index d2378d4..4086fac 100644 --- a/src/anchorloop/project.py +++ b/src/anchorloop/project.py @@ -41,6 +41,7 @@ BRIEF_FIELDS = ("outcome", "scope", "constraints", "invariant", "uncertainty") VERIFICATION_RESULTS = {"pass", "fail", "partial", "not-applicable"} CAREFUL_RECALL_DELAY_HOURS = 24 +TASK_SCHEMA_VERSION = 2 # These are deliberately conservative signals. They promote a task only when # an engineer explicitly names a sensitive area or provides a recognisably @@ -423,7 +424,7 @@ def _pending_recalls(self) -> list[dict[str, Any]]: for path in sorted(self.fs.glob(closed_directory, "*.json")): try: task = self._read_json(path) - self.validate_task_schema(task) + self._validate_closed_task_integrity(task, path) comprehension = task.get("comprehension") if not isinstance(comprehension, dict): continue @@ -562,6 +563,22 @@ def safe_exists(path: Path, name: str) -> bool | None: elif task_exists is False: checks.append({"name": "active-task", "status": "not-run", "detail": "no active task"}) + try: + closed_directory = self.anchor_dir / "tasks" / "closed" + closed_paths = ( + sorted(self.fs.glob(closed_directory, "*.json")) + if self._exists(closed_directory) + else [] + ) + for closed_path in closed_paths: + self._validate_closed_task_integrity( + self._read_json(closed_path), + closed_path, + ) + checks.append({"name": "closed-task-integrity", "status": "passed"}) + except Exception as error: + failed("closed-task-integrity", error) + try: closed_directory = self.anchor_dir / "tasks" / "closed" closed_paths = ( @@ -647,6 +664,7 @@ def start_task(self, title: str) -> dict[str, Any]: raise AnchorError(f"Task {task['id']} is already active in state {task['state']}.") normalized_title = self._required_text(title, "Task title") task = { + "schema_version": TASK_SCHEMA_VERSION, "id": f"al-{datetime.now(UTC).strftime('%Y%m%d')}-{uuid4().hex[:6]}", "title": normalized_title, "state": "briefing", @@ -843,20 +861,23 @@ def approve_task( task = self._task_in_state("planned", "approve") if "plan" not in task: raise AnchorError("A recorded plan is required before approval.") + self._normalize_task_schema_for_approval(task) snapshot = self._active_rules_snapshot() task["ruleset"] = snapshot approval_subject = self._task_approval_subject(task) subject_digest = self._document_digest(approval_subject) if provenance == "interactive-tty" and expected_subject_digest != subject_digest: raise AnchorError("The approval subject changed after it was displayed; review and confirm it again.") - task["approval"] = { + approval = { **self._approval_record(by, provenance, interactive_confirmed=interactive_confirmed), "plan_summary": task["plan"]["summary"], **self._task_approval_digests(approval_subject), "ruleset_version": snapshot["version"], } if provenance == "interactive-tty": - task["approval"]["confirmed_subject_digest"] = subject_digest + approval["confirmed_subject_digest"] = subject_digest + approval["record_digest"] = self._record_digest(approval) + task["approval"] = approval return self._advance_task(task, "approve", "approved") @consistent_read("task.approval-preview") @@ -866,6 +887,7 @@ def task_approval_preview(self) -> dict[str, Any]: task = deepcopy(self._task_in_state("planned", "approve")) if "plan" not in task: raise AnchorError("A recorded plan is required before approval.") + self._normalize_task_schema_for_approval(task) task["ruleset"] = self._active_rules_snapshot() subject = self._task_approval_subject(task) return { @@ -950,9 +972,15 @@ def verify_task( **self._approval_record(engineer, provenance, interactive_confirmed=interactive_confirmed), "result": normalized_result, "reason": normalized_reason, + "subject_digest": subject_digest, + "reported_metrics": self._verification_metric_snapshot(task), + "comprehension_digest": self._document_digest( + task.get("comprehension", {}).get("verification_check") + ), } if provenance == "interactive-tty": verification["confirmed_subject_digest"] = subject_digest + verification["record_digest"] = self._record_digest(verification) task["verification"] = verification if normalized_result == "fail": self._append_task_event(task, "task.verify.failed") @@ -998,7 +1026,7 @@ def record_delayed_recall( response: str, score: int, ) -> dict[str, Any]: - if not TASK_ID_PATTERN.fullmatch(task_id): + if not isinstance(task_id, str) or not TASK_ID_PATTERN.fullmatch(task_id): raise AnchorError("Invalid AnchorLoop task ID.") if not isinstance(score, int) or isinstance(score, bool) or not 0 <= score <= 5: raise AnchorError("Delayed recall score must be an integer from 0 to 5.") @@ -1006,18 +1034,7 @@ def record_delayed_recall( if not self._exists(path): raise AnchorError(f"Closed task '{task_id}' does not exist.") task = self._read_json(path) - self.validate_task_schema(task) - if task.get("id") != task_id or task.get("state") != "closed": - raise AnchorError("Delayed recall requires an intact closed task record.") - approval = task.get("approval") - expected_approval = self._task_approval_digests(self._task_approval_subject(task)) - if ( - not isinstance(approval, dict) - or approval.get("task_digest") != expected_approval["task_digest"] - ): - raise AnchorError( - "Closed task ownership or recall policy changed after approval; delayed recall is blocked." - ) + self._validate_closed_task_integrity(task, path) comprehension = task.get("comprehension") if not isinstance(comprehension, dict) or not comprehension.get("recall_due_at"): raise AnchorError("This task has no scheduled delayed recall.") @@ -1051,7 +1068,7 @@ def record_post_completion_outcome( corrective_refactor: bool, notes: str, ) -> dict[str, Any]: - if not TASK_ID_PATTERN.fullmatch(task_id): + if not isinstance(task_id, str) or not TASK_ID_PATTERN.fullmatch(task_id): raise AnchorError("Invalid AnchorLoop task ID.") if ( not isinstance(defects_found, int) @@ -1065,18 +1082,7 @@ def record_post_completion_outcome( if not self._exists(path): raise AnchorError(f"Closed task '{task_id}' does not exist.") task = self._read_json(path) - self.validate_task_schema(task) - if task.get("id") != task_id or task.get("state") != "closed": - raise AnchorError("Post-completion outcome requires an intact closed task record.") - approval = task.get("approval") - expected_approval = self._task_approval_digests(self._task_approval_subject(task)) - if ( - not isinstance(approval, dict) - or approval.get("task_digest") != expected_approval["task_digest"] - ): - raise AnchorError( - "Closed task ownership changed after approval; post-completion outcome is blocked." - ) + self._validate_closed_task_integrity(task, path) record = { "at": self._timestamp(), "by": self._required_text(by, "Engineer name"), @@ -1101,9 +1107,7 @@ def experiment_report(self) -> dict[str, Any]: closed_directory = self.anchor_dir / "tasks" / "closed" for path in sorted(self.fs.glob(closed_directory, "*.json")): task = self._read_json(path) - self.validate_task_schema(task) - if task.get("state") != "closed" or task.get("id") != path.stem: - raise AnchorError(f"Closed task record is inconsistent: {path}") + self._validate_closed_task_integrity(task, path) metrics = task.get("metrics") if isinstance(task.get("metrics"), dict) else {} comprehension = ( task.get("comprehension") @@ -1261,9 +1265,12 @@ def transition(self, action: str) -> dict[str, Any]: closed_at = self._closed_at(task) delay = self._recall_delay_hours(comprehension) comprehension["recall_due_at"] = (closed_at + timedelta(hours=delay)).isoformat() + if action == "close": + task["close_digest"] = self._closed_task_digest(task) self._append_task_event(task, f"task.{action}") if action == "close": closed_path = self.anchor_dir / "tasks" / "closed" / f"{task['id']}.json" + self._validate_closed_task_integrity(task, closed_path) self._write_json(closed_path, task) self._unlink(self.active_task_path) recall_due_at = ( @@ -2088,8 +2095,10 @@ def _read_json(self, path: Path) -> dict[str, Any]: def _write_json(self, path: Path, data: dict[str, Any]) -> None: candidate = self.fs.validate(path) closed_tasks = self.anchor_dir / "tasks" / "closed" - if candidate == self.active_task_path or candidate.parent == closed_tasks: + if candidate == self.active_task_path: self.validate_task_schema(data) + elif candidate.parent == closed_tasks: + self._validate_closed_task_integrity(data, candidate) self._write_text(path, json.dumps(data, indent=2, sort_keys=True, allow_nan=False) + "\n") def _write_json_if_missing(self, path: Path, data: dict[str, Any]) -> None: @@ -2211,6 +2220,9 @@ def validate_task_schema(cls, task: object) -> None: if not isinstance(task, dict): raise AnchorError("Anchor task state must be a JSON object.") + schema_version = task.get("schema_version") + if schema_version is not None and schema_version != TASK_SCHEMA_VERSION: + raise AnchorError("Anchor task has an unsupported schema version.") task_id = task.get("id") if not isinstance(task_id, str) or not TASK_ID_PATTERN.fullmatch(task_id): raise AnchorError("Anchor task has an invalid ID.") @@ -2220,6 +2232,7 @@ def validate_task_schema(cls, task: object) -> None: if state not in allowed_states: raise AnchorError(f"Anchor task has an invalid state: {state!r}.") cls._required_text(task.get("created_at"), "Task creation time") + cls._created_at(task) brief = cls.validate_brief_fields(task.get("brief"), allow_unfilled=True) brief_record = task.get("brief_record") brief_is_complete = all(value is not None for value in brief.values()) @@ -2258,9 +2271,79 @@ def validate_task_schema(cls, task: object) -> None: raise AnchorError("Anchor task plan must be an object.") for field in ("summary", "approach", "rejected_alternative", "primary_risk", "verification_strategy"): cls._required_text(plan.get(field), f"Anchor task plan {field}") + rollback_mitigation = plan.get("rollback_mitigation") + if rollback_mitigation is not None: + cls._required_text(rollback_mitigation, "Anchor task plan rollback mitigation") + if mode == "CAREFUL" and rollback_mitigation is None: + raise AnchorError("CAREFUL task ownership evidence requires rollback or mitigation guidance.") + + metrics = task.get("metrics") + if not isinstance(metrics, dict): + raise AnchorError("Anchor task metrics must be an object.") + cls.validate_metric_types( + agent_turns=metrics.get("agent_turns"), + input_tokens=metrics.get("input_tokens"), + output_tokens=metrics.get("output_tokens"), + active_minutes=metrics.get("active_minutes"), + agent_provider=metrics.get("agent_provider"), + agent_model=metrics.get("agent_model"), + ) + has_reported_metrics = any( + metrics.get(field) is not None + for field in ( + "agent_turns", + "input_tokens", + "output_tokens", + "active_minutes", + "agent_provider", + "agent_model", + ) + ) + expected_metric_provenance = "reported-at-verification" if has_reported_metrics else None + if metrics.get("provenance") != expected_metric_provenance: + raise AnchorError("Anchor task metric provenance does not match its reported metrics.") + + human_artifact = task.get("human_artifact") + if human_artifact is not None: + if not isinstance(human_artifact, dict): + raise AnchorError("Anchor task human ownership artifact must be an object.") + for field in ("content", "by", "at", "source"): + cls._required_text( + human_artifact.get(field), + f"Anchor task human ownership artifact {field}", + ) comprehension = task.get("comprehension") if not isinstance(comprehension, dict): raise AnchorError("Anchor task comprehension policy must be an object.") + baseline = comprehension.get("baseline") + if baseline is not None: + if not isinstance(baseline, dict): + raise AnchorError("Anchor task comprehension baseline must be an object.") + for field in ("statement", "by", "at"): + cls._required_text( + baseline.get(field), + f"Anchor task comprehension baseline {field}", + ) + if mode in {"STANDARD", "CAREFUL"} and ( + human_artifact is None or baseline is None + ): + raise AnchorError( + f"{mode} task ownership evidence requires a human artifact and comprehension baseline." + ) + + verification_check = comprehension.get("verification_check") + if verification_check is not None: + if not isinstance(verification_check, dict): + raise AnchorError("Anchor task verification comprehension must be an object.") + for field in ("statement", "by", "at"): + cls._required_text( + verification_check.get(field), + f"Anchor task verification comprehension {field}", + ) + + due_at = comprehension.get("recall_due_at") + if due_at is not None: + cls._required_text(due_at, "Delayed recall due time") if mode == "CAREFUL": if "recall_delay_hours" in comprehension: delay = comprehension.get("recall_delay_hours") @@ -2272,34 +2355,245 @@ def validate_task_schema(cls, task: object) -> None: raise AnchorError( "CAREFUL tasks must retain the approved 24-hour delayed-recall policy." ) + if state == "closed" and due_at is None: + raise AnchorError("Closed CAREFUL tasks require a delayed-recall schedule.") + if state != "closed" and due_at is not None: + raise AnchorError( + "CAREFUL delayed recall must be scheduled from the close time, not before close." + ) elif comprehension.get("recall_due_at") is None: raise AnchorError( "Legacy CAREFUL tasks must retain their approved delayed-recall timestamp." ) - elif comprehension.get("recall_delay_hours") is not None: - raise AnchorError("Only CAREFUL tasks may carry a delayed-recall policy.") - due_at = comprehension.get("recall_due_at") - if due_at is not None: - cls._required_text(due_at, "Delayed recall due time") + elif ( + comprehension.get("recall_delay_hours") is not None + or due_at is not None + or comprehension.get("delayed_recall") is not None + ): + raise AnchorError("Only CAREFUL tasks may carry a delayed-recall policy or record.") + + delayed_recall = comprehension.get("delayed_recall") + if delayed_recall is not None: + if not isinstance(delayed_recall, dict): + raise AnchorError("Delayed recall record must be an object.") + for field in ("statement", "by", "due_at", "recorded_at"): + cls._required_text(delayed_recall.get(field), f"Delayed recall {field}") + score = delayed_recall.get("score") + if not isinstance(score, int) or isinstance(score, bool) or not 0 <= score <= 5: + raise AnchorError("Delayed recall score must be an integer from 0 to 5.") + delay_seconds = delayed_recall.get("delay_seconds") + if ( + not isinstance(delay_seconds, (int, float)) + or isinstance(delay_seconds, bool) + or not math.isfinite(float(delay_seconds)) + or delay_seconds < 0 + ): + raise AnchorError("Delayed recall delay must be a finite non-negative number.") + + approved_states = { + "approved", + "implementing", + "review_ready", + "quality_checked", + "verified", + "closed", + } + if state in approved_states: + approval = task.get("approval") + if not isinstance(approval, dict): + raise AnchorError("Approved task states require an approval record.") + for field in ( + "by", + "at", + "provenance", + "brief_digest", + "brief_record_digest", + "plan_digest", + "ownership_digest", + "ruleset_digest", + "task_digest", + "ruleset_version", + ): + cls._required_text(approval.get(field), f"Anchor task approval {field}") + if approval.get("provenance") not in APPROVAL_PROVENANCE: + raise AnchorError("Anchor task approval has invalid provenance.") + confirmed_approval_digest = approval.get("confirmed_subject_digest") + if confirmed_approval_digest is not None: + cls._required_text( + confirmed_approval_digest, + "Anchor task confirmed approval subject digest", + ) + approval_record_digest = approval.get("record_digest") + if schema_version == TASK_SCHEMA_VERSION and approval_record_digest is None: + raise AnchorError("Current task records require an approval record digest.") + if approval_record_digest is not None: + cls._required_text( + approval_record_digest, + "Anchor task approval record digest", + ) + if approval_record_digest != cls._record_digest(approval): + raise AnchorError("Anchor task approval record digest does not match.") + ruleset = task.get("ruleset") + if not isinstance(ruleset, dict): + raise AnchorError("Approved task states require a ruleset snapshot.") + cls._required_text(ruleset.get("version"), "Anchor task ruleset version") + if not isinstance(ruleset.get("rules"), dict) or not isinstance( + ruleset.get("documents"), dict + ): + raise AnchorError("Anchor task ruleset snapshot is invalid.") + + quality = task.get("quality") + if quality is not None and ( + not isinstance(quality, list) + or not all(isinstance(item, dict) for item in quality) + ): + raise AnchorError("Anchor task quality evidence must be a list of objects.") + evidence_states = {"quality_checked", "verified", "closed"} + if state in evidence_states: + if not quality or quality[-1].get("status") != "passed": + raise AnchorError("Quality-checked task states require passing quality evidence.") + if state in {"verified", "closed"}: verification = task.get("verification") if not isinstance(verification, dict): raise AnchorError("Verified tasks require a verification record.") - cls.validate_verification_result(verification.get("result")) + result = cls.validate_verification_result(verification.get("result")) + if result == "fail": + raise AnchorError("Verified or closed tasks cannot retain a failed verification result.") cls._required_text(verification.get("reason"), "Verification reason") + for field in ("by", "at", "provenance"): + cls._required_text(verification.get(field), f"Anchor task verification {field}") + if verification.get("provenance") not in APPROVAL_PROVENANCE: + raise AnchorError("Anchor task verification has invalid provenance.") + for field in ("subject_digest", "confirmed_subject_digest"): + value = verification.get(field) + if value is not None: + cls._required_text(value, f"Anchor task verification {field}") + if ( + schema_version == TASK_SCHEMA_VERSION + and verification.get("subject_digest") is None + ): + raise AnchorError( + "Current task records require a verification subject digest." + ) + reported_metrics = verification.get("reported_metrics") + expected_reported_metrics = cls._verification_metric_snapshot(task) + if schema_version == TASK_SCHEMA_VERSION and reported_metrics is None: + raise AnchorError("Current task records require a verification metric snapshot.") + if reported_metrics is not None: + if not isinstance(reported_metrics, dict) or reported_metrics != expected_reported_metrics: + raise AnchorError( + "Anchor task verification metric snapshot no longer matches stored metrics." + ) + comprehension_digest = verification.get("comprehension_digest") + expected_comprehension_digest = cls._document_digest(verification_check) + if schema_version == TASK_SCHEMA_VERSION and comprehension_digest is None: + raise AnchorError("Current task records require a verification comprehension digest.") + if comprehension_digest is not None: + cls._required_text( + comprehension_digest, + "Anchor task verification comprehension digest", + ) + if comprehension_digest != expected_comprehension_digest: + raise AnchorError( + "Anchor task verification comprehension digest does not match." + ) + verification_record_digest = verification.get("record_digest") + if schema_version == TASK_SCHEMA_VERSION and verification_record_digest is None: + raise AnchorError("Current task records require a verification record digest.") + if verification_record_digest is not None: + cls._required_text( + verification_record_digest, + "Anchor task verification record digest", + ) + if verification_record_digest != cls._record_digest(verification): + raise AnchorError("Anchor task verification record digest does not match.") + if mode in {"STANDARD", "CAREFUL"} and verification_check is None: + raise AnchorError( + f"{mode} verified tasks require a verification comprehension statement." + ) + if state == "closed": + created_at = cls._created_at(task) + closed_at = cls._closed_at(task) + if closed_at < created_at: + raise AnchorError("Closed task timestamp cannot precede task creation.") + wall_seconds = metrics.get("wall_seconds") + if ( + wall_seconds is not None + and ( + not isinstance(wall_seconds, (int, float)) + or isinstance(wall_seconds, bool) + or not math.isfinite(float(wall_seconds)) + or wall_seconds < 0 + ) + ): + raise AnchorError("Closed task duration must be a finite non-negative number.") + if schema_version == TASK_SCHEMA_VERSION and wall_seconds is None: + raise AnchorError("Current closed task records require a close duration.") + if wall_seconds is not None: + expected_wall_seconds = (closed_at - created_at).total_seconds() + if not math.isclose( + float(wall_seconds), + expected_wall_seconds, + rel_tol=0.0, + abs_tol=1e-6, + ): + raise AnchorError( + "Closed task duration does not match its creation and close timestamps." + ) + close_digest = task.get("close_digest") + approval_evidence = task.get("approval") + verification_evidence = task.get("verification") + has_current_integrity_evidence = ( + schema_version == TASK_SCHEMA_VERSION + or ( + isinstance(approval_evidence, dict) + and approval_evidence.get("record_digest") is not None + ) + or ( + isinstance(verification_evidence, dict) + and any( + verification_evidence.get(field) is not None + for field in ( + "record_digest", + "reported_metrics", + "comprehension_digest", + ) + ) + ) + ) + if has_current_integrity_evidence and close_digest is None: + raise AnchorError("Current closed task records require a close digest.") + if close_digest is not None: + cls._required_text(close_digest, "Anchor task close digest") + if close_digest != cls._closed_task_digest(task): + raise AnchorError("Anchor task close digest does not match immutable close evidence.") + + @staticmethod + def _created_at(task: dict[str, Any]) -> datetime: + value = task.get("created_at") + if not isinstance(value, str): + raise AnchorError("Task is missing its creation timestamp.") + try: + created_at = datetime.fromisoformat(value) + except ValueError as error: + raise AnchorError("Task has an invalid creation timestamp.") from error + if created_at.tzinfo is None: + raise AnchorError("Task creation timestamp must include a timezone.") + return created_at @staticmethod def _closed_at(task: dict[str, Any]) -> datetime: metrics = task.get("metrics") value = metrics.get("closed_at") if isinstance(metrics, dict) else None if not isinstance(value, str): - raise AnchorError("Closed CAREFUL task is missing its close timestamp.") + raise AnchorError("Closed task is missing its close timestamp.") try: closed_at = datetime.fromisoformat(value) except ValueError as error: - raise AnchorError("Closed CAREFUL task has an invalid close timestamp.") from error + raise AnchorError("Closed task has an invalid close timestamp.") from error if closed_at.tzinfo is None: - raise AnchorError("Closed CAREFUL task timestamp must include a timezone.") + raise AnchorError("Closed task timestamp must include a timezone.") return closed_at @staticmethod @@ -2357,12 +2651,180 @@ def _scheduled_recall_due_at(self, task: dict[str, Any]) -> datetime: ) return due_at + def _validate_closed_task_integrity( + self, + task: dict[str, Any], + path: Path, + ) -> None: + """Validate immutable close evidence without comparing it to the live workspace.""" + + prefix = f"Closed task record is inconsistent: {path}" + try: + self.validate_task_schema(task) + if task.get("state") != "closed" or task.get("id") != path.stem: + raise AnchorError("task state or ID does not match its closed-task path") + + approval = task.get("approval") + expected_approval = self._task_approval_digests( + self._task_approval_subject(task) + ) + if not isinstance(approval, dict): + raise AnchorError("approval evidence is missing") + changed_approval = [ + field + for field, digest in expected_approval.items() + if approval.get(field) != digest + ] + if changed_approval: + raise AnchorError( + "approval evidence no longer matches: " + + ", ".join(changed_approval) + ) + confirmed_approval = approval.get("confirmed_subject_digest") + if ( + confirmed_approval is not None + and confirmed_approval != expected_approval["task_digest"] + ): + raise AnchorError("confirmed approval subject no longer matches") + ruleset = task.get("ruleset") + if ( + not isinstance(ruleset, dict) + or approval.get("ruleset_version") != ruleset.get("version") + ): + raise AnchorError("approval ruleset version no longer matches") + + quality = task.get("quality") + if ( + not isinstance(quality, list) + or not quality + or not isinstance(quality[-1], dict) + or quality[-1].get("status") != "passed" + ): + raise AnchorError("latest quality evidence is not passing") + + verification = task.get("verification") + if not isinstance(verification, dict): + raise AnchorError("verification evidence is missing") + result = verification.get("result") + if result not in {"pass", "partial", "not-applicable"}: + raise AnchorError("closing verification result is not accepted") + comprehension = task.get("comprehension") + verification_check = ( + comprehension.get("verification_check") + if isinstance(comprehension, dict) + else None + ) + recall = ( + verification_check.get("statement") + if isinstance(verification_check, dict) + else None + ) + expected_verification_digest = self._verification_subject_digest( + task, + result=result, + reason=verification.get("reason"), + recall=recall, + ) + recorded_verification_digest = verification.get("subject_digest") + if ( + recorded_verification_digest is not None + and recorded_verification_digest != expected_verification_digest + ): + raise AnchorError("verification subject no longer matches") + confirmed_verification_digest = verification.get( + "confirmed_subject_digest" + ) + if ( + confirmed_verification_digest is not None + and confirmed_verification_digest != expected_verification_digest + ): + raise AnchorError("confirmed verification subject no longer matches") + + self._closed_at(task) + if task.get("mode") == "CAREFUL": + scheduled_due_at = self._scheduled_recall_due_at(task) + delayed_recall = ( + comprehension.get("delayed_recall") + if isinstance(comprehension, dict) + else None + ) + if ( + isinstance(delayed_recall, dict) + and delayed_recall.get("due_at") != scheduled_due_at.isoformat() + ): + raise AnchorError("delayed recall record does not match its schedule") + except (AnchorError, KeyError, TypeError, ValueError) as error: + raise AnchorError(f"{prefix}: {error}") from error + @staticmethod def _document_digest(document: Any) -> str: encoded = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8") return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + @classmethod + def _record_digest(cls, record: dict[str, Any]) -> str: + return cls._document_digest( + {key: value for key, value in record.items() if key != "record_digest"} + ) + + @staticmethod + def _verification_metric_snapshot(task: dict[str, Any]) -> dict[str, Any]: + metrics = task.get("metrics") if isinstance(task.get("metrics"), dict) else {} + return { + field: metrics.get(field) + for field in ( + "agent_turns", + "input_tokens", + "output_tokens", + "active_minutes", + "agent_provider", + "agent_model", + "provenance", + ) + } + + @classmethod + def _closed_task_subject(cls, task: dict[str, Any]) -> dict[str, Any]: + comprehension = task.get("comprehension") + close_comprehension = { + field: comprehension.get(field) + for field in ( + "verification_check", + "recall_delay_hours", + "recall_due_at", + ) + } if isinstance(comprehension, dict) else None + metrics = task.get("metrics") if isinstance(task.get("metrics"), dict) else {} + return { + "schema_version": task.get("schema_version"), + "task_id": task.get("id"), + "state": task.get("state"), + "created_at": task.get("created_at"), + "approval_subject": cls._task_approval_subject(task), + "approval": task.get("approval"), + "quality": task.get("quality"), + "verification": task.get("verification"), + "comprehension": close_comprehension, + "metrics": { + **cls._verification_metric_snapshot(task), + "closed_at": metrics.get("closed_at"), + "wall_seconds": metrics.get("wall_seconds"), + }, + } + + @classmethod + def _closed_task_digest(cls, task: dict[str, Any]) -> str: + return cls._document_digest(cls._closed_task_subject(task)) + + @staticmethod + def _normalize_task_schema_for_approval(task: dict[str, Any]) -> None: + # A planned legacy-shaped record has no immutable approval yet, so this + # is the safe migration boundary. Preview and mutation must normalize + # identically or an interactive confirmation can never match. + if task.get("schema_version") is None: + task["schema_version"] = TASK_SCHEMA_VERSION + @staticmethod def _task_approval_subject(task: dict[str, Any]) -> dict[str, Any]: comprehension = task.get("comprehension") @@ -2382,7 +2844,7 @@ def _task_approval_subject(task: dict[str, Any]) -> dict[str, Any]: } else: comprehension_policy = None - return { + subject = { "task_id": task.get("id"), "title": task.get("title"), "mode": task.get("mode"), @@ -2394,6 +2856,9 @@ def _task_approval_subject(task: dict[str, Any]) -> dict[str, Any]: "comprehension_policy": comprehension_policy, "ruleset": task.get("ruleset"), } + if "schema_version" in task: + subject["schema_version"] = task.get("schema_version") + return subject def _task_approval_digests(self, subject: dict[str, Any]) -> dict[str, str]: return { diff --git a/src/anchorloop/skill_install.py b/src/anchorloop/skill_install.py index a6aca3c..0ca1fc5 100644 --- a/src/anchorloop/skill_install.py +++ b/src/anchorloop/skill_install.py @@ -75,6 +75,9 @@ class SkillPlatform: SKILL_RUNTIME_NPX = "npx" SUPPORTED_SKILL_RUNTIMES = (SKILL_RUNTIME_ANCHOR, SKILL_RUNTIME_NPX) _MARKER_NAME = ".anchorloop-skill.json" +_SKILL_MARKER_SCHEMA = 2 +_SKILL_OWNED_PATHS = frozenset({"SKILL.md", "references/workflow.md"}) +_SKILL_SHA256_PATTERN = re.compile(r"sha256:[0-9a-f]{64}") _INSTALL_JOURNAL_NAME = "skill-install-journal.json" _INSTALL_JOURNAL_SCHEMA = 2 _NPX_PACKAGE_PATTERN = re.compile( @@ -308,9 +311,13 @@ def install( previous_files: dict[str, str | None] = {} if filesystem.exists(marker_path): previous_marker = self._read_marker(filesystem, marker_path) - if previous_marker.get("platform") != platform: - raise AnchorError(f"Skill marker at {destination} is for a different platform.") - previous_files = self._marker_files(previous_marker, marker_path) + self._validate_marker_identity( + previous_marker, + marker_path, + platform=platform, + project_scoped=project_scoped, + ) + previous_files = self._marker_files(previous_marker, marker_path, force=force) self._require_unmodified_assets( filesystem, destination, @@ -326,7 +333,7 @@ def install( } installed_version = self._installed_version() marker = { - "schema_version": 2, + "schema_version": _SKILL_MARKER_SCHEMA, "skill": SKILL_NAME, "platform": platform, "scope": "project" if project_scoped else "user-global", @@ -391,11 +398,13 @@ def uninstall( raise AnchorError(f"No AnchorLoop skill installation is recorded at {destination}.") marker = self._read_marker(filesystem, marker_path) - recorded_platform = marker.get("platform") - if recorded_platform != platform: - raise AnchorError(f"Skill marker at {destination} is for platform '{recorded_platform}', not '{platform}'.") - - paths = self._marker_files(marker, marker_path) + self._validate_marker_identity( + marker, + marker_path, + platform=platform, + project_scoped=project_scoped, + ) + paths = self._marker_files(marker, marker_path, force=force) self._require_unmodified_assets(filesystem, destination, paths, force=force, operation="uninstall") journal = self._uninstall_journal( filesystem=filesystem, @@ -435,6 +444,12 @@ def installation_status(self, *, platform: str, project_scoped: bool) -> dict[st "recovery_pending": False, } marker = self._read_marker(filesystem, marker_path) + self._validate_marker_identity( + marker, + marker_path, + platform=platform, + project_scoped=project_scoped, + ) integrity = "ok" bundle_current = False try: @@ -765,6 +780,15 @@ def _decode_install_journal( delete_names.add(name) deletes.append((relative_path, before)) + if not delete_names.issubset(_SKILL_OWNED_PATHS): + raise AnchorError("Skill recovery journal contains a delete outside owned assets.") + if action == "install" and write_names != _SKILL_OWNED_PATHS: + raise AnchorError("Skill install recovery journal does not contain the canonical asset bundle.") + if action == "uninstall" and write_names: + raise AnchorError("Skill uninstall recovery journal contains writes.") + if action == "uninstall" and delete_names != _SKILL_OWNED_PATHS: + raise AnchorError("Skill uninstall recovery journal does not contain the canonical asset bundle.") + raw_marker = journal.get("marker") if action == "install": marker_path, marker_content, marker_before = self._decode_journal_file( @@ -780,6 +804,12 @@ def _decode_install_journal( raise AnchorError("Skill recovery journal marker content is invalid.") from error if not isinstance(marker, dict) or marker.get("skill") != SKILL_NAME: raise AnchorError("Skill recovery journal marker content is invalid.") + self._validate_marker_identity( + marker, + destination / _MARKER_NAME, + platform=str(journal["platform"]), + project_scoped=bool(journal["project_scoped"]), + ) expected_scope = "project" if journal["project_scoped"] else "user-global" if ( marker.get("platform") != journal["platform"] @@ -796,8 +826,8 @@ def _decode_install_journal( if marker_files != expected_files: raise AnchorError("Skill recovery journal marker does not match its writes.") else: - if writes or not isinstance(raw_marker, dict): - raise AnchorError("Skill uninstall recovery journal contains writes.") + if not isinstance(raw_marker, dict): + raise AnchorError("Skill uninstall recovery journal marker is invalid.") marker_name = raw_marker.get("path") if not isinstance(marker_name, str): raise AnchorError("Skill recovery journal marker path is invalid.") @@ -1083,7 +1113,37 @@ def _read_marker(filesystem: SafeProjectFS, path: Path) -> dict[str, Any]: return data @staticmethod - def _marker_files(marker: dict[str, Any], marker_path: Path) -> dict[str, str | None]: + def _validate_marker_identity( + marker: dict[str, Any], + marker_path: Path, + *, + platform: str, + project_scoped: bool, + ) -> None: + expected_scope = "project" if project_scoped else "user-global" + if marker.get("schema_version") != _SKILL_MARKER_SCHEMA: + raise AnchorError(f"AnchorLoop skill marker at {marker_path} has an unsupported schema.") + if marker.get("platform") != platform or marker.get("scope") != expected_scope: + raise AnchorError(f"AnchorLoop skill marker at {marker_path} is for a different destination.") + version = marker.get("version") + runtime = marker.get("runtime", SKILL_RUNTIME_ANCHOR) + npx_package = marker.get("npx_package") + if ( + not isinstance(version, str) + or not version.strip() + or not isinstance(runtime, str) + or (npx_package is not None and not isinstance(npx_package, str)) + ): + raise AnchorError(f"AnchorLoop skill marker at {marker_path} has invalid metadata.") + SkillInstaller._validate_runtime(runtime, npx_package) + + @staticmethod + def _marker_files( + marker: dict[str, Any], + marker_path: Path, + *, + force: bool = False, + ) -> dict[str, str | None]: paths = marker.get("files") if not isinstance(paths, list): raise AnchorError(f"AnchorLoop skill marker at {marker_path} is invalid.") @@ -1091,15 +1151,51 @@ def _marker_files(marker: dict[str, Any], marker_path: Path) -> dict[str, str | result: dict[str, str | None] = {} for entry in paths: if isinstance(entry, str): - result[entry] = None - continue - if not isinstance(entry, dict): + path = entry + digest: str | None = None + if not force: + raise AnchorError( + f"AnchorLoop skill marker at {marker_path} has legacy entries; rerun with --force." + ) + elif isinstance(entry, dict): + path = entry.get("path") + digest = entry.get("sha256") + if not isinstance(path, str) or not isinstance(digest, str): + raise AnchorError(f"AnchorLoop skill marker at {marker_path} is invalid.") + if _SKILL_SHA256_PATTERN.fullmatch(digest) is None: + raise AnchorError(f"AnchorLoop skill marker at {marker_path} has an invalid digest.") + else: raise AnchorError(f"AnchorLoop skill marker at {marker_path} is invalid.") - path = entry.get("path") - digest = entry.get("sha256") - if not isinstance(path, str) or not isinstance(digest, str): + + relative = Path(path) + if ( + not path + or "\\" in path + or relative.is_absolute() + or relative.drive + or relative.root + or any(part in {"", ".", ".."} for part in relative.parts) + or relative.as_posix() != path + ): raise AnchorError(f"AnchorLoop skill marker at {marker_path} is invalid.") + if path in result: + raise AnchorError(f"AnchorLoop skill marker at {marker_path} contains duplicate paths.") result[path] = digest + + unknown = sorted(set(result) - _SKILL_OWNED_PATHS) + missing = sorted(_SKILL_OWNED_PATHS - set(result)) + if not force and (unknown or missing): + details: list[str] = [] + if unknown: + details.append(f"unknown owned paths: {', '.join(unknown)}") + if missing: + details.append(f"missing owned paths: {', '.join(missing)}") + raise AnchorError( + f"AnchorLoop skill marker at {marker_path} does not match the canonical bundle " + f"({'; '.join(details)})." + ) + if force: + return {path: result.get(path) for path in sorted(_SKILL_OWNED_PATHS)} return result def _require_unmodified_assets( diff --git a/tests/git_fixture.py b/tests/git_fixture.py new file mode 100644 index 0000000..80a6f09 --- /dev/null +++ b/tests/git_fixture.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def init_git_repository(root: Path) -> None: + """Initialize the minimal Git worktree required by actual-diff checks.""" + if (root / ".git").exists(): + return + subprocess.run( + ["git", "init", "--quiet"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index edf3333..b88a0e5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ from anchorloop.cli import main from anchorloop.quality import workspace_fingerprint +from tests.git_fixture import init_git_repository def record_brief(root: Path) -> int: @@ -37,6 +38,7 @@ def record_brief(root: Path) -> int: def record_plan(root: Path, summary: str) -> int: + init_git_repository(root) return main( [ "plan", @@ -1042,8 +1044,13 @@ def test_project_skill_install_is_previewed_and_uninstalls_only_owned_files(self } ) marker_path.write_text(json.dumps(marker), encoding="utf-8") - self.assertEqual(main(["install", "--project", "--apply", "--path", str(root)]), 0) - self.assertFalse(legacy_asset.exists()) + self.assertEqual(main(["install", "--project", "--apply", "--path", str(root)]), 2) + self.assertTrue(legacy_asset.exists()) + self.assertEqual( + main(["install", "--project", "--apply", "--force", "--path", str(root)]), + 0, + ) + self.assertTrue(legacy_asset.exists()) note = destination / "user-note.txt" note.write_text("keep me\n", encoding="utf-8") diff --git a/tests/test_core_validation.py b/tests/test_core_validation.py index acb74ab..635a235 100644 --- a/tests/test_core_validation.py +++ b/tests/test_core_validation.py @@ -8,6 +8,7 @@ from pathlib import Path from anchorloop.project import AnchorError, AnchorProject +from tests.git_fixture import init_git_repository BRIEF = { @@ -32,6 +33,7 @@ def _project_ready_for_verification( *, mode: str = "STANDARD", ) -> AnchorProject: + init_git_repository(root) project = self._project_in_briefing(root) project.record_brief(by="Ada Engineer", values=BRIEF) project.plan_task( @@ -113,6 +115,75 @@ def test_public_verify_rejects_invalid_result_and_metric_types_before_staging(se project.verify_task(**common, **metrics) # type: ignore[arg-type] self.assertEqual(active_path.read_bytes(), before) + def test_current_task_approval_attribution_is_integrity_protected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._project_ready_for_verification(root) + active_path = root / ".anchor" / "tasks" / "active.json" + task = json.loads(active_path.read_text(encoding="utf-8")) + task["approval"]["by"] = "Mallory" + active_path.write_text(json.dumps(task), encoding="utf-8") + + with self.assertRaisesRegex(AnchorError, "approval record digest"): + project.status() + + def test_newly_closed_legacy_shaped_task_receives_close_integrity_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + init_git_repository(root) + project = self._project_in_briefing(root) + project.record_brief(by="Ada Engineer", values=BRIEF) + project.plan_task( + "Persist the retry attempt before delivery.", + mode="STANDARD", + task_type="feature", + approach="Write retry state before calling the destination.", + rejected_alternative="An in-memory counter loses state after restart.", + primary_risk="A retry could acknowledge one event twice.", + verification_strategy="Exercise duplicate and transient-failure scenarios.", + human_artifact="Acceptance case: one delivery acknowledgement per event.", + comprehension="Persisted state prevents duplicate acknowledgement.", + by="Ada Engineer", + ) + active_path = root / ".anchor" / "tasks" / "active.json" + legacy_shaped = json.loads(active_path.read_text(encoding="utf-8")) + legacy_shaped.pop("schema_version") + active_path.write_text(json.dumps(legacy_shaped), encoding="utf-8") + + preview = project.task_approval_preview() + project.approve_task( + "Ada Engineer", + provenance="interactive-tty", + interactive_confirmed=True, + expected_subject_digest=preview["subject_digest"], + ) + project.transition("implement") + project.transition("review") + project.precommit() + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted retry state prevents duplicate acknowledgement.", + ) + closed = project.transition("close") + self.assertEqual(closed["schema_version"], 2) + self.assertIn("close_digest", closed) + + closed_path = root / ".anchor" / "tasks" / "closed" / f"{closed['id']}.json" + forged = json.loads(closed_path.read_text(encoding="utf-8")) + forged.pop("close_digest") + shift = timedelta(days=365) + forged["created_at"] = ( + datetime.fromisoformat(forged["created_at"]) + shift + ).isoformat() + forged["metrics"]["closed_at"] = ( + datetime.fromisoformat(forged["metrics"]["closed_at"]) + shift + ).isoformat() + closed_path.write_text(json.dumps(forged), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "close digest"): + project.experiment_report() + def test_auto_risk_uses_russian_path_and_explicit_rule_signals(self) -> None: russian_brief = { **BRIEF, @@ -171,6 +242,12 @@ def test_legacy_careful_recall_record_remains_usable(self) -> None: comprehension = legacy["comprehension"] comprehension.pop("recall_delay_hours") comprehension["recall_due_at"] = legacy_due_at.isoformat() + legacy.pop("schema_version") + legacy["verification"].pop("subject_digest") + legacy["verification"].pop("reported_metrics") + legacy["verification"].pop("comprehension_digest") + legacy["verification"].pop("record_digest") + legacy["approval"].pop("record_digest") legacy["approval"].update( project._task_approval_digests(project._task_approval_subject(legacy)) ) @@ -214,6 +291,84 @@ def test_task_schema_rejects_manually_malformed_brief_before_transition(self) -> with self.assertRaisesRegex(AnchorError, "fields must be exact"): project.record_brief(by="Ada Engineer", values=BRIEF) + def test_task_schema_requires_standard_ownership_evidence_before_approval(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._project_in_briefing(root) + project.record_brief(by="Ada Engineer", values=BRIEF) + project.plan_task( + "Persist the retry attempt before delivery.", + mode="STANDARD", + task_type="feature", + approach="Write retry state before calling the destination.", + rejected_alternative="An in-memory counter loses state after restart.", + primary_risk="A retry could acknowledge one event twice.", + verification_strategy="Exercise duplicate and transient-failure scenarios.", + human_artifact="Acceptance case: one acknowledgement per event.", + comprehension="Persisted state prevents duplicate acknowledgement.", + by="Ada Engineer", + ) + active_path = root / ".anchor" / "tasks" / "active.json" + baseline = json.loads(active_path.read_text(encoding="utf-8")) + for field in ("human_artifact", "comprehension"): + with self.subTest(field=field): + tampered = json.loads(json.dumps(baseline)) + if field == "human_artifact": + tampered[field] = None + else: + tampered[field]["baseline"] = None + active_path.write_text(json.dumps(tampered), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "ownership evidence"): + project.approve_task("Ada Engineer") + active_path.write_text(json.dumps(baseline), encoding="utf-8") + + def test_closed_task_public_methods_reject_non_text_task_ids(self) -> None: + with tempfile.TemporaryDirectory() as directory: + project = AnchorProject.at(Path(directory)) + project.apply_setup("add") + for task_id in (None, 1, b"task", Path("task")): + with self.subTest(task_id=task_id): + with self.assertRaisesRegex(AnchorError, "Invalid AnchorLoop task ID"): + project.record_delayed_recall( + task_id=task_id, # type: ignore[arg-type] + by="Ada Engineer", + response="Recall", + score=3, + ) + with self.assertRaisesRegex(AnchorError, "Invalid AnchorLoop task ID"): + project.record_post_completion_outcome( + task_id=task_id, # type: ignore[arg-type] + by="Ada Engineer", + defects_found=0, + rollback=False, + corrective_refactor=False, + notes="No issue.", + ) + + def test_task_schema_requires_approval_and_quality_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._project_ready_for_verification(root) + active_path = root / ".anchor" / "tasks" / "active.json" + baseline = json.loads(active_path.read_text(encoding="utf-8")) + cases = ( + ("approval", "approval record"), + ("quality", "passing quality evidence"), + ) + for field, message in cases: + with self.subTest(field=field): + tampered = json.loads(json.dumps(baseline)) + tampered.pop(field) + active_path.write_text(json.dumps(tampered), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, message): + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted state prevents duplicate acknowledgement.", + ) + active_path.write_text(json.dumps(baseline), encoding="utf-8") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_human_ownership.py b/tests/test_human_ownership.py index 28b3378..9d1a248 100644 --- a/tests/test_human_ownership.py +++ b/tests/test_human_ownership.py @@ -11,6 +11,7 @@ from anchorloop.cli import main from anchorloop.project import AnchorError, AnchorProject +from tests.git_fixture import init_git_repository def _brief(root: Path) -> None: @@ -38,6 +39,7 @@ def _brief(root: Path) -> None: def _structured_plan(root: Path, *, mode: str = "STANDARD") -> list[str]: + init_git_repository(root) return [ "plan", "--summary", @@ -187,6 +189,70 @@ def test_standard_verification_requires_recall_and_records_metrics(self) -> None self.assertEqual(report["tasks"][0]["defects_found"], 1) self.assertTrue(report["tasks"][0]["corrective_refactor"]) + closed_path = root / ".anchor" / "tasks" / "closed" / f"{task_id}.json" + closed = json.loads(closed_path.read_text(encoding="utf-8")) + self.assertIn("subject_digest", closed["verification"]) + for label, path, value in ( + ( + "verification reason", + ("verification", "reason"), + "Forged verification evidence.", + ), + ("verification engineer", ("verification", "by"), "Mallory"), + ( + "verification time", + ("verification", "at"), + "2035-01-01T00:00:00+00:00", + ), + ("approval engineer", ("approval", "by"), "Mallory"), + ( + "approval time", + ("approval", "at"), + "2035-01-01T00:00:00+00:00", + ), + ("reported metric value", ("metrics", "input_tokens"), 1201), + ("reported metric type", ("metrics", "input_tokens"), "1200"), + ("close duration", ("metrics", "wall_seconds"), 999999.0), + ( + "task creation time", + ("created_at",), + "2035-01-01T00:00:00+00:00", + ), + ): + with self.subTest(label=label): + forged = json.loads(json.dumps(closed)) + if len(path) == 1: + forged[path[0]] = value + else: + forged[path[0]][path[1]] = value + closed_path.write_text(json.dumps(forged), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "Closed task record is inconsistent"): + AnchorProject.at(root).experiment_report() + forged = json.loads(json.dumps(closed)) + forged["verification"].update( + { + "provenance": "interactive-tty", + "confirmation": "typed", + "confirmed_subject_digest": forged["verification"]["subject_digest"], + } + ) + closed_path.write_text(json.dumps(forged), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "Closed task record is inconsistent"): + AnchorProject.at(root).experiment_report() + doctor = AnchorProject.at(root).doctor(strict=True) + self.assertTrue( + any( + check["name"] == "closed-task-integrity" and check["status"] == "failed" + for check in doctor["checks"] + ) + ) + stripped = json.loads(json.dumps(closed)) + stripped["verification"].pop("subject_digest") + closed_path.write_text(json.dumps(stripped), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "verification subject digest"): + AnchorProject.at(root).experiment_report() + closed_path.write_text(json.dumps(closed), encoding="utf-8") + csv_output = io.StringIO() with redirect_stdout(csv_output): self.assertEqual( diff --git a/tests/test_integrity_regressions.py b/tests/test_integrity_regressions.py index 1afce5e..33d5ffc 100644 --- a/tests/test_integrity_regressions.py +++ b/tests/test_integrity_regressions.py @@ -8,6 +8,7 @@ from anchorloop.cli import main from anchorloop.project import AnchorError, AnchorProject from anchorloop.skill_install import SkillInstaller +from tests.git_fixture import init_git_repository def _run(root: Path, *arguments: str) -> int: @@ -42,6 +43,7 @@ def _plan(root: Path, summary: str) -> int: def _approved_task(root: Path) -> None: + init_git_repository(root) assert _run(root, "add", "--apply") == 0 assert _run(root, "start", "Protect workflow integrity") == 0 assert ( diff --git a/tests/test_skill_install_matrix.py b/tests/test_skill_install_matrix.py index d963d64..43584a6 100644 --- a/tests/test_skill_install_matrix.py +++ b/tests/test_skill_install_matrix.py @@ -204,6 +204,88 @@ def test_global_all_uses_only_native_destinations(self) -> None: self.assertFalse(home.joinpath(*location, "skills", "anchorloop").exists()) self.assertFalse((home / ".agents").exists()) + def test_marker_tampering_never_expands_uninstall_ownership(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + installer.install(platform="agents", project_scoped=True) + note = destination / "user-note.txt" + note.write_text("preserve me\n", encoding="utf-8") + marker_path = destination / ".anchorloop-skill.json" + marker = json.loads(marker_path.read_text(encoding="utf-8")) + marker["files"].append( + { + "path": "user-note.txt", + "sha256": f"sha256:{hashlib.sha256(note.read_bytes()).hexdigest()}", + } + ) + marker_path.write_text(json.dumps(marker), encoding="utf-8") + + with self.assertRaisesRegex(AnchorError, "unknown owned paths"): + installer.uninstall(platform="agents", project_scoped=True) + self.assertTrue(note.is_file()) + self.assertTrue((destination / "SKILL.md").is_file()) + + installer.uninstall(platform="agents", project_scoped=True, force=True) + self.assertTrue(note.is_file()) + self.assertFalse(marker_path.exists()) + self.assertFalse((destination / "SKILL.md").exists()) + + def test_force_update_repairs_injected_marker_without_deleting_user_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="codex", project_scoped=True) + installer.install(platform="codex", project_scoped=True) + note = destination / "user-note.txt" + note.write_text("preserve me\n", encoding="utf-8") + marker_path = destination / ".anchorloop-skill.json" + marker = json.loads(marker_path.read_text(encoding="utf-8")) + marker["files"].append( + { + "path": "user-note.txt", + "sha256": f"sha256:{hashlib.sha256(note.read_bytes()).hexdigest()}", + } + ) + marker_path.write_text(json.dumps(marker), encoding="utf-8") + + installer.install(platform="codex", project_scoped=True, force=True) + self.assertEqual(note.read_text(encoding="utf-8"), "preserve me\n") + repaired = json.loads(marker_path.read_text(encoding="utf-8")) + self.assertEqual( + {entry["path"] for entry in repaired["files"]}, + {"SKILL.md", "references/workflow.md"}, + ) + + def test_force_does_not_accept_malformed_marker_entries(self) -> None: + malformed_entries = ( + [ + {"path": "SKILL.md", "sha256": "sha256:" + "0" * 64}, + {"path": "SKILL.md", "sha256": "sha256:" + "0" * 64}, + ], + [ + {"path": "SKILL.md", "sha256": "not-a-digest"}, + {"path": "references/workflow.md", "sha256": "sha256:" + "0" * 64}, + ], + [ + {"path": "../outside.txt", "sha256": "sha256:" + "0" * 64}, + {"path": "SKILL.md", "sha256": "sha256:" + "0" * 64}, + ], + ) + for entries in malformed_entries: + with self.subTest(entries=entries), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + installer.install(platform="agents", project_scoped=True) + marker_path = destination / ".anchorloop-skill.json" + marker = json.loads(marker_path.read_text(encoding="utf-8")) + marker["files"] = entries + marker_path.write_text(json.dumps(marker), encoding="utf-8") + with self.assertRaises(AnchorError): + installer.install(platform="agents", project_scoped=True, force=True) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_skill_install_recovery.py b/tests/test_skill_install_recovery.py index 36dc55b..7e0aa1b 100644 --- a/tests/test_skill_install_recovery.py +++ b/tests/test_skill_install_recovery.py @@ -53,8 +53,8 @@ def test_pending_journal_paths_are_validated_before_recovery_mutates_destination installer.install(platform="agents", project_scoped=True) self.assertFalse(escaped.exists()) self.assertFalse(destination.exists()) - finally: - journal_filesystem.unlink(journal_path, missing_ok=True) + finally: + journal_filesystem.unlink(journal_path, missing_ok=True) def test_interrupted_initial_install_is_reported_without_mutation_then_rolls_forward(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -115,7 +115,6 @@ def test_interrupted_update_replays_the_journaled_bundle_idempotently(self) -> N if path.as_posix() == "SKILL.md": content += b"\nJournal recovery update.\n" updated_assets.append((path, content)) - updated_assets.append((Path("references/recovery.md"), b"Recovered bundle.\n")) original_write = SkillInstaller._write_bytes writes = 0 @@ -147,12 +146,11 @@ def interrupted_write(filesystem: SafeProjectFS, path: Path, content: bytes) -> "Journal recovery update.", (destination / "SKILL.md").read_text(encoding="utf-8"), ) + marker = json.loads((destination / ".anchorloop-skill.json").read_text(encoding="utf-8")) self.assertEqual( - (destination / "references" / "recovery.md").read_text(encoding="utf-8"), - "Recovered bundle.\n", + {"SKILL.md", "references/workflow.md"}, + {entry["path"] for entry in marker["files"]}, ) - marker = json.loads((destination / ".anchorloop-skill.json").read_text(encoding="utf-8")) - self.assertIn("references/recovery.md", {entry["path"] for entry in marker["files"]}) _, journal_path = installer._journal_context(destination) self.assertFalse(journal_path.exists()) @@ -201,6 +199,36 @@ def interrupted_unlink( _, journal_path = installer._journal_context(destination) self.assertFalse(journal_path.exists()) + def test_uninstall_recovery_requires_the_complete_owned_asset_bundle(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + installer = SkillInstaller(root) + destination = installer.destination_for(platform="agents", project_scoped=True) + installer.install(platform="agents", project_scoped=True) + filesystem = installer._filesystem_for(True) + marker = json.loads( + (destination / ".anchorloop-skill.json").read_text(encoding="utf-8") + ) + journal = installer._uninstall_journal( + filesystem=filesystem, + destination=destination, + platform="agents", + project_scoped=True, + version=marker["version"], + runtime=marker["runtime"], + owned_files=[], + ) + journal_filesystem, journal_path = installer._journal_context(destination) + journal_filesystem.atomic_write_text(journal_path, json.dumps(journal) + "\n") + try: + with self.assertRaisesRegex(AnchorError, "canonical asset bundle"): + installer.uninstall(platform="agents", project_scoped=True) + self.assertTrue((destination / "SKILL.md").is_file()) + self.assertTrue((destination / "references" / "workflow.md").is_file()) + self.assertTrue((destination / ".anchorloop-skill.json").is_file()) + finally: + journal_filesystem.unlink(journal_path, missing_ok=True) + def test_recovery_stops_an_opposite_requested_mutation(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -295,8 +323,8 @@ def interrupted_write(filesystem: SafeProjectFS, path: Path, content: bytes) -> installer.install(platform="agents", project_scoped=True) self.assertEqual(skill.read_text(encoding="utf-8"), "post-crash user edit\n") self.assertTrue(journal_path.is_file()) - finally: - journal_filesystem.unlink(journal_path, missing_ok=True) + finally: + journal_filesystem.unlink(journal_path, missing_ok=True) def test_recovery_refuses_to_delete_a_post_crash_recreated_file(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_transaction.py b/tests/test_transaction.py index f801d1b..7c1bf5d 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -20,6 +20,7 @@ TransactionRecoveryRequired, _digest, ) +from tests.git_fixture import init_git_repository class _FailAfterFirstOperation(TransactionManager): @@ -52,6 +53,7 @@ def _crash_between_state_and_event(root: str) -> None: def _review_ready_project(root: Path) -> AnchorProject: + init_git_repository(root) project = AnchorProject.at(root) project.apply_setup("add") project.start_task("Serialize workflow commands") From dcf35a7f3edf9272e8f83e81602049cf3b634211 Mon Sep 17 00:00:00 2001 From: ppmarkek <153396079+ppmarkek@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:04:10 +0300 Subject: [PATCH 3/5] Refresh delivery loop and evidence integrity diagrams --- docs/assets/anchorloop-delivery-loop.svg | 213 ++++++++++-------- docs/assets/anchorloop-evidence-integrity.svg | 160 +++++++------ 2 files changed, 203 insertions(+), 170 deletions(-) diff --git a/docs/assets/anchorloop-delivery-loop.svg b/docs/assets/anchorloop-delivery-loop.svg index 5f7459b..c299f19 100644 --- a/docs/assets/anchorloop-delivery-loop.svg +++ b/docs/assets/anchorloop-delivery-loop.svg @@ -1,113 +1,136 @@ - + AnchorLoop delivery loop - A numbered workflow from task through brief, plan, engineer approval, implementation, review and precommit, verification, and close. A failed verification remains recorded and returns explicitly to implementation or to planning and approval. + The ordered AnchorLoop workflow moves from task, brief, and plan through engineer approval, implementation, review and precommit, verification, and close. A failed verification remains recorded and returns explicitly to implementation when scope is unchanged, or to planning and approval when the scope or approach changes. - - + + - + - + - Delivery stays engineer-controlled - Implementation starts only after approval. Every later gate records evidence before work can close. - + + + + 01 + Task + Outcome + scope + + 02 + Brief + Constraints + acceptance + + 03 + Plan + Approach + verification + + 04 + Approval + Engineer gate + + 05 + Implement + Approved change + + 06 + Review, then precommit + Checks + fingerprint + + 07 + Verify + Observed result + + 08 + Close + Evidence still matches + - - - 01 - Task - Outcome and scope - set by the engineer - - - - 02 - Brief - Constraints and - acceptance case - - - - 03 - Plan - Approach, trade-off, - and verification - - - - 04 - Engineer approval - Pins brief, plan, - and ruleset - - - - 05 - Implement - Agent writes the - approved change - - - - 06 - Review + precommit - Checks and fingerprint - become evidence - - - - 07 - Verify - Engineer records the - observed result - - - - 08 - Close - Evidence still matches; - the task can close + + + Failed result remains recorded + SAME SCOPE + 05 Implement + SCOPE OR APPROACH CHANGED + 03 Plan → 04 Approval - - - - - ! - Verification fails - The failed result stays recorded. - - SAME SCOPE - Return to implementation - SCOPE OR SOLUTION CHANGED - Revise plan, then re-approve - - AnchorLoop records workflow evidence; it does not authenticate the person providing approval. diff --git a/docs/assets/anchorloop-evidence-integrity.svg b/docs/assets/anchorloop-evidence-integrity.svg index 80ec892..3a7807a 100644 --- a/docs/assets/anchorloop-evidence-integrity.svg +++ b/docs/assets/anchorloop-evidence-integrity.svg @@ -1,102 +1,112 @@ - + AnchorLoop evidence integrity - Approved brief, plan, and ruleset are pinned in an approval record. Precommit checks and a workspace fingerprint create quality evidence. Verification and close require both records to remain current; changed inputs archive approval, and changed workspace content requires review and precommit again. + The engineer-approved subject, including brief, plan, human ownership evidence, and active rules, creates an approval record. Precommit checks and a materialized workspace fingerprint create quality evidence. Verification and close are separate ordered gates and require both records to remain current. A changed approved subject requires revision and re-approval; a changed checked workspace requires review and precommit again. - + - + - Evidence expires when reality changes - Verify and close stay available only while the approved inputs and checked workspace still match. - + + Approved subject + brief · plan · ownership · rules - - - - APPROVED INPUTS - + - Plan - Approach and verification - + - Precommit checks - Syntax + whitespace - Approved security rules + + Approval record + engineer-approved subject - Workspace fingerprint - SHA-256 over checked - workspace + submodules + Quality evidence + checks + SHA-256 fingerprint - - FINAL GATE - - - Verify / close - Allowed only while - approval and quality - evidence still match. - - NO STALE EVIDENCE + Verify + Close + both records current - - - ! - APPROVED INPUT CHANGED - Archive approval → revise and re-approve + + + Approved subject changed + archive → revise → re-approve - - - ! - WORKSPACE CHANGED AFTER CHECKS - Return to review → rerun precommit + + + Checked workspace changed + review + precommit again - - Audit evidence, not proof of identity or access control. From 0911a210912deaf26a7d53e12be5e6af03a278c3 Mon Sep 17 00:00:00 2001 From: ppmarkek <153396079+ppmarkek@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:27:25 +0300 Subject: [PATCH 4/5] Refactor marketplace frontend and shared layout flows --- .github/workflows/release.yml | 258 ++++- CHANGELOG.md | 11 +- CONTRIBUTING.md | 59 +- README.md | 38 +- SECURITY.md | 11 +- docs/ANCHOR_DECISION_MAP.md | 22 +- docs/MIGRATION_0.2.md | 41 +- docs/PORTABLE_SKILL.md | 15 +- docs/PROJECT_PLAN.md | 47 +- docs/i18n/README.de.md | 2 +- docs/i18n/README.es.md | 2 +- docs/i18n/README.fr.md | 2 +- docs/i18n/README.ja.md | 2 +- docs/i18n/README.pt-BR.md | 2 +- docs/i18n/README.ru.md | 2 +- docs/i18n/README.zh-CN.md | 2 +- npm/scripts/finalize-release-docs.js | 777 +++++++++++++++ npm/test/anchorloop.test.js | 5 +- npm/test/release-docs.test.js | 436 +++++++++ package.json | 2 +- src/anchorloop/cli.py | 3 + src/anchorloop/project.py | 555 ++++++++++- src/anchorloop/quality.py | 1308 ++++++++++++++++++++++++-- src/anchorloop/safe_fs.py | 28 +- tests/test_actual_diff_risk.py | 1033 +++++++++++++++++++- tests/test_cli.py | 9 +- tests/test_core_validation.py | 270 ++++++ tests/test_filesystem_safety.py | 48 + tests/test_release_version.py | 269 ++++++ tests/test_skill_install_matrix.py | 2 +- 30 files changed, 5017 insertions(+), 244 deletions(-) create mode 100644 npm/scripts/finalize-release-docs.js create mode 100644 npm/test/release-docs.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd09fc8..f9dc8ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,12 @@ on: push: tags: - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: Exact public npm version to verify after staged approval + required: true + type: string permissions: contents: read @@ -15,12 +21,14 @@ concurrency: jobs: exact-tag-ci: name: Green CI for tagged commit + if: github.event_name == 'push' uses: ./.github/workflows/ci.yml permissions: contents: read publish-npm: - name: Publish npm package + name: Stage npm package for human approval + if: github.event_name == 'push' needs: exact-tag-ci runs-on: ubuntu-latest environment: npm-release @@ -109,6 +117,17 @@ jobs: exit 1 } + - name: Require tagged commit to be merged into main + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + tagged_commit="$(git rev-parse HEAD)" + git merge-base --is-ancestor "$tagged_commit" refs/remotes/origin/main || { + echo "Release commit ${tagged_commit} is not contained in origin/main." >&2 + exit 1 + } + - name: Require the existing npm package identity shell: bash env: @@ -125,26 +144,213 @@ jobs: exit 1 } - - name: Build the exact publishable archive - id: archive + - name: Prepare time-invariant release artifact documentation + id: artifact_docs shell: bash + env: + VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail - mkdir -p dist-npm - npm pack --pack-destination dist-npm - mapfile -t archives < <(find dist-npm -maxdepth 1 -type f -name '*.tgz' -print) - test "${#archives[@]}" -eq 1 || { - echo "Expected exactly one npm tarball, found ${#archives[@]}." >&2 + release_date="$(git show -s --format=%cs HEAD)" + [[ "$release_date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { + echo "Tagged commit did not provide a YYYY-MM-DD release date." >&2 exit 1 } - printf 'path=%s\n' "${archives[0]}" >> "$GITHUB_OUTPUT" + node npm/scripts/finalize-release-docs.js \ + --version "$VERSION" \ + --release-date "$release_date" + printf 'release_date=%s\n' "$release_date" >> "$GITHUB_OUTPUT" - - name: Publish with npm provenance through trusted publishing - run: npm publish "${{ steps.archive.outputs.path }}" --access public --provenance + - name: Verify release artifact documentation and mutation boundary + shell: bash + env: + RELEASE_DATE: ${{ steps.artifact_docs.outputs.release_date }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + node npm/scripts/finalize-release-docs.js \ + --check \ + --version "$VERSION" \ + --release-date "$RELEASE_DATE" + + if ! git diff --cached --quiet --; then + echo "Release documentation preparation staged changes; the index must remain untouched." >&2 + git diff --cached --name-only -- >&2 + exit 1 + fi - - name: Smoke-test the public registry release + mapfile -d '' -t untracked_paths < <(git ls-files --others --exclude-standard -z) + if ((${#untracked_paths[@]})); then + printf 'Unexpected untracked release path: %s\n' "${untracked_paths[@]}" >&2 + exit 1 + fi + + git diff --exit-code -- \ + src npm package.json package-lock.json pyproject.toml || { + echo "Release documentation preparation changed runtime or release tooling." >&2 + exit 1 + } + + while IFS= read -r changed_path; do + case "$changed_path" in + README.md|CHANGELOG.md|SECURITY.md|CONTRIBUTING.md|docs/MIGRATION_0.2.md|docs/ANCHOR_DECISION_MAP.md|docs/PORTABLE_SKILL.md|docs/PROJECT_PLAN.md|docs/i18n/README.*.md) + ;; + *) + echo "Unexpected release artifact mutation: ${changed_path}" >&2 + exit 1 + ;; + esac + done < <(git diff --name-only) + + - name: Dry-run package assembly from the finalized checkout + shell: bash + run: | + set -euo pipefail + npm pack --dry-run --ignore-scripts + + - name: Stage candidate with npm provenance through trusted publishing + run: >- + npm stage publish . + --ignore-scripts + --access public + --provenance + --tag next + + - name: Record the human approval handoff + shell: bash env: - ANCHORLOOP_RELEASE_TAG: ${{ github.ref_name }} + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + { + echo "## Staged npm release" + echo + echo "${PACKAGE_NAME}@${VERSION} is staged from the signed Git checkout under the next tag." + echo "A maintainer must inspect the exact staged artifact with npm stage list/view/download," + echo "then run npm stage approve and complete 2FA." + echo "After approval, dispatch the signed tag-bound verification exactly:" + echo '```sh' + echo "gh workflow run release.yml --ref v${VERSION} -f version=${VERSION}" + echo '```' + echo "No workflow secret or long-lived publish token is used." + } >> "$GITHUB_STEP_SUMMARY" + + verify-public-registry: + name: Verify approved public registry release + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: refs/tags/v${{ inputs.version }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22.14.0" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Use the release npm client + shell: bash + run: | + set -euo pipefail + npm install --global npm@11.18.0 + test "$(npm --version)" = "11.18.0" + + - name: Install the locked release dependencies + run: npm ci --ignore-scripts + + - name: Verify requested version and exact release tag + id: release + shell: bash + env: + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + [[ "$REQUESTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { + echo "Version must be an exact semver value without a leading v." >&2 + exit 1 + } + expected_ref="refs/tags/v${REQUESTED_VERSION}" + test "$GITHUB_REF" = "$expected_ref" || { + echo "Registry verification must run from ${expected_ref}, not ${GITHUB_REF}." >&2 + echo "Use: gh workflow run release.yml --ref v${REQUESTED_VERSION} -f version=${REQUESTED_VERSION}" >&2 + exit 1 + } + canonical_version="$(node -p "require('./npm/scripts/version.js').readCanonicalVersion(process.cwd())")" + test "$canonical_version" = "$REQUESTED_VERSION" || { + echo "Requested ${REQUESTED_VERSION}, but the tag contains ${canonical_version}." >&2 + exit 1 + } + ANCHORLOOP_RELEASE_TAG="v${REQUESTED_VERSION}" npm run version:check + printf 'version=%s\n' "$canonical_version" >> "$GITHUB_OUTPUT" + printf 'package_name=%s\n' "$(node -p "require('./package.json').name")" >> "$GITHUB_OUTPUT" + + - name: Revalidate GitHub-verified signed annotated tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: v${{ inputs.version }} + run: | + set -euo pipefail + ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")" + object_type="$(jq -r '.object.type' <<< "$ref_json")" + object_sha="$(jq -r '.object.sha' <<< "$ref_json")" + test "$object_type" = "tag" || { + echo "Release tags must remain annotated and signed; ${RELEASE_TAG} is ${object_type}." >&2 + exit 1 + } + + tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${object_sha}")" + verified="$(jq -r '.verification.verified' <<< "$tag_json")" + target_type="$(jq -r '.object.type' <<< "$tag_json")" + target_sha="$(jq -r '.object.sha' <<< "$tag_json")" + test "$verified" = "true" || { + echo "GitHub no longer verifies the signature on ${RELEASE_TAG}." >&2 + exit 1 + } + test "$target_type" = "commit" || { + echo "${RELEASE_TAG} must point directly to a commit." >&2 + exit 1 + } + test "$target_sha" = "$(git rev-parse HEAD)" || { + echo "${RELEASE_TAG} does not point to the checked-out commit." >&2 + exit 1 + } + + - name: Require tagged commit to remain in main + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "$(git rev-parse HEAD)" refs/remotes/origin/main || { + echo "Release tag is no longer contained in origin/main." >&2 + exit 1 + } + + - name: Require exact version to be the approved next candidate + shell: bash + env: + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + candidate_version="$(npm view "${PACKAGE_NAME}@next" version)" + test "$candidate_version" = "$VERSION" || { + echo "npm next resolves to ${candidate_version}, expected ${VERSION}." >&2 + exit 1 + } + + - name: Smoke-test the exact public registry release + env: + ANCHORLOOP_RELEASE_TAG: v${{ inputs.version }} run: >- node npm/scripts/registry-smoke.js --package "${{ steps.release.outputs.package_name }}" @@ -157,12 +363,34 @@ jobs: VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail - published_head="$(npm view "${PACKAGE_NAME}@${VERSION}" gitHead)" expected_head="$(git rev-parse HEAD)" - test "$published_head" = "$expected_head" || { echo "Published gitHead ${published_head} does not match ${expected_head}." >&2 exit 1 } + - name: Record the human-owned promotion command + shell: bash + env: + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + { + echo "## Public registry verification passed" + echo + echo "Promotion remains an interactive maintainer action with 2FA:" + echo '```sh' + echo "npm dist-tag add ${PACKAGE_NAME}@${VERSION} latest" + echo "npm view ${PACKAGE_NAME}@latest version" + echo '```' + echo + echo "After npm confirms ${PACKAGE_NAME}@latest is ${VERSION}, open a" + echo "post-promotion documentation PR against main. Update every source" + echo "status surface and its release-document tests from 0.1.0 production /" + echo "${VERSION} candidate to ${VERSION} production. Update or retire the" + echo "${VERSION}-specific finalizer source fragments and source-RC assertions" + echo "before preparing the next release cycle, and leave the signed" + echo "v${VERSION} artifact unchanged." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1cd08..de3430b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ availability. ## 0.2.0 - Unreleased Published production remains `anchorloop@0.1.0` until the signed `v0.2.0` tag -passes the complete release workflow. +is contained in `main` and passes staging, maintainer approval, exact-version +registry smoke, and interactive `latest` promotion. ### Added @@ -30,9 +31,11 @@ passes the complete release workflow. - recovery completes an interrupted project or skill transaction and stops the current call without automatically running the newly requested mutation; -- release publication is OIDC-only and requires a verified signed annotated - tag, green exact-tag CI, an unpublished exact npm version, and matching npm - `gitHead`; +- release publication requires a verified signed annotated tag contained in + `origin/main`, green exact-tag CI, and an unpublished exact npm version. The + exact tarball is staged through OIDC under `next`, inspected and approved by + a maintainer with 2FA, smoke-tested by exact public version, checked for a + matching npm `gitHead`, and only then promoted interactively to `latest`; - generated local skill installations are ignored and are no longer repository source of truth; - the npm package no longer depends recursively on `anchorloop@0.1.0`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 611fe24..b60d26d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ temporary files, or symlink-following state access outside that boundary. ## Release safety -Production npm releases are tag-driven. Configure npm trusted publishing for +Production npm releases are signed-tag driven. Configure npm trusted publishing for the `ppmarkek/AnchorLoop` repository, `.github/workflows/release.yml`, and the protected `npm-release` GitHub environment. Keep the CI jobs as required checks on `main`. Do not add a long-lived npm token to the workflow. @@ -68,25 +68,44 @@ on `main`. Do not add a long-lived npm token to the workflow. Create a signed annotated tag whose name exactly matches the canonical version (for example, `git tag -s v0.2.0 -m "AnchorLoop 0.2.0"`) and ensure the signing key is associated with the maintainer's GitHub account before pushing -the tag. The release workflow reruns the full Python, wheel, npm, OS, and Node -18/20/22 matrix for the tagged commit; checks the GitHub-verified tag -signature; builds the exact tarball; publishes it with npm provenance through -OIDC; then runs a clean registry-backed install, task lifecycle, uninstall, -and residue check. - -This is an exact-source, gated release—not a bit-for-bit reproducible build. -Hosted runner images and Python build tooling still receive upstream updates; -do not claim binary reproducibility without a separately pinned toolchain and -runner-image policy. - -`anchorloop@0.1.0` is the published production baseline. npm versions are -immutable: before creating `v0.2.0`, verify that `anchorloop@0.2.0` does not -exist. The release workflow repeats that check, refuses an existing exact -version, and verifies the published `gitHead` after the registry smoke. Never -publish `0.2.0` manually, move a published tag, or attempt to overwrite a -failed release. Fix pre-publish failures in a new commit; for a post-publish -defect, deprecate the affected version and release `0.2.1`. Do not weaken -`release.yml` or add a long-lived token. +the tag. The tagged source remains truthful about its pre-release state. After +the full Python, wheel, npm, OS, and Node 18/20/22 matrix passes, a disposable +publish job deterministically transforms only declared packaged documentation +using the tagged commit date. It rejects staged changes, non-ignored untracked +paths, and tracked mutations outside the documentation allowlist before a +dry-run of package assembly. Both the dry-run and staging disable lifecycle +scripts so the checked payload cannot mutate afterward. The job then passes the +finalized Git checkout directory to `npm stage publish` under `next` with +provenance through stage-only OIDC, preserving npm's internal directory-derived +`gitHead`; it never calls `npm publish` or promotes `latest`. + +This produces deterministic packaged documentation and one reviewed tarball +from the signed source and commit date, but it is not a bit-for-bit binary +reproducibility claim. Hosted runner images and Python build tooling still +receive upstream updates; do not claim binary reproducibility without a +separately pinned toolchain and runner-image policy. + +`anchorloop@0.1.0` remains the published production baseline while `0.2.0` is +an unreleased candidate. npm versions are immutable: before creating `v0.2.0`, +verify that `anchorloop@0.2.0` does not exist. The signed-tag workflow runs +exact-tag CI against the truthful repository docs, then changes only packaged +documentation in its disposable job using the tagged commit date. It dry-runs +package assembly with lifecycle scripts disabled, then stages the finalized Git +checkout directory under `next` with lifecycle scripts disabled through +stage-only OIDC so npm records `gitHead`. A maintainer downloads and inspects +the exact staged tarball, approves it with 2FA, dispatches the +tag-bound exact registry smoke, verifies npm `gitHead`, and only then promotes +the version to `latest` manually with 2FA. After +`npm view anchorloop@latest version` returns +`0.2.0`, open a post-promotion documentation PR against `main`. That PR must +move every source status surface and its release-document tests from `0.1.0` +production / `0.2.0` candidate to `0.2.0` production. It must also update or +retire the `0.2.0`-specific finalizer source fragments and source-RC assertions +before preparing the next release cycle. Leave the signed `v0.2.0` artifact +unchanged. Never publish `0.2.0` directly, move a signed tag, or overwrite a +failed release. Fix pre-stage failures in a new commit; after approval, +deprecate a defective version and release `0.2.1`. Do not weaken `release.yml` +or add a long-lived token. ## Pull requests diff --git a/README.md b/README.md index 82742a8..ede816d 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ AnchorLoop lets an AI agent implement code without taking ownership away from th **Unreleased main:** `0.2.0` release candidate -The published `0.1.0` package remains the production version. Current `main` -contains the unreleased recovery, validation, ownership, release-safety, and -multi-agent installer work planned for `0.2.0`. Until the signed `v0.2.0` tag -passes the complete release workflow, do not describe those additions as +The published `0.1.0` package remains the production version. The current +release branch contains the unreleased recovery, validation, ownership, +release-safety, and multi-agent installer work planned for `0.2.0`. Until the +signed `v0.2.0` tag passes staging, maintainer approval, exact-version registry +smoke, and interactive `latest` promotion, do not describe those additions as available from npm `latest`. ## Core idea @@ -42,7 +43,7 @@ downgrade is visible in the task record and requires a reason. ## Delivery loop -AnchorLoop task flow from task through brief, plan, engineer approval, implementation, review, precommit, verification, and close, with an explicit revision route after failed verification. +AnchorLoop task flow from task through brief, plan, engineer approval, implementation, review, precommit, verification, and close, with an explicit revision route after failed verification. AnchorLoop records the delivery loop: implementation follows an engineer-approved plan, and a failed verification returns through an explicit revision rather than @@ -94,10 +95,12 @@ Every host gets the same task states and approval rules. Native integrations mus failing health check, and `doctor --repair` explicitly recovers an interrupted transaction or torn final event-log entry. - Failed verification is preserved and can explicitly return to implementation or planning with `anchor revise`; it no longer strands the active task. -- The quality gate records a deterministic workspace fingerprint. Verification and close are blocked when the checked code changes afterward. -- Before `review_ready` and again before `precommit`, AnchorLoop evaluates the - actual Git diff. CAREFUL paths block a lower-mode task until the engineer - revises the plan or records a path-specific reviewed override. +- The quality gate records deterministic workspace and Git-snapshot fingerprints. Verification and close are blocked when checked content, `HEAD`, or index state changes afterward. +- Before `review_ready` and again before `precommit`, AnchorLoop evaluates + changes from the task baseline: the actual Git diff when a committed `HEAD` + exists, or a deterministic materialized-file fallback in unborn and non-Git + workspaces. CAREFUL paths block a lower-mode task until the engineer revises + the plan or records a path-specific reviewed override. - `anchor init` and `anchor add` preview project setup and require `--apply` before creating files. - Setup creates portable `.anchor/` state, baseline **proposed** rules, Graphify integration metadata, a portable agent protocol, and a generated next-action file. - The task flow enforces `start → brief → plan → approve → implement → review → precommit → verify → close`. @@ -119,8 +122,8 @@ npx --yes anchorloop@0.1.0 install --project --platform codex --apply ~~~ Do not use an unversioned `npx anchorloop install` command to test the features -documented below: npm `latest` still resolves to `0.1.0` until the `0.2.0` -release completes. +documented below: npm `latest` continues to resolve to `0.1.0` until the +`0.2.0` release flow completes. ## Unreleased 0.2.0 guided setup @@ -190,8 +193,9 @@ migration refreshes managed protocol/support files and skill assets while preserving task, rule, approval, and audit records. If an installed skill was edited locally, review and preserve that diff before using `--force`. -See the [0.1.0 to 0.2.0 migration guide](docs/MIGRATION_0.2.md) for the current -release-candidate procedure and the exact commands to use after publication. +See the [0.1.0 to 0.2.0 migration guide](docs/MIGRATION_0.2.md) for the required +`0.1.0` recovery preflight, release-candidate procedure, and exact commands to +use after publication. ## Development from a checkout @@ -333,11 +337,11 @@ It also records that project-specific formatter, linter, type-checker, test-runn Each successful run stores a SHA-256 fingerprint of the materialized tracked and non-ignored untracked files. Git HEAD, index, and diff state are recorded -separately for diagnostics, so an ordinary commit does not invalidate evidence -when the checked files are byte-for-byte unchanged. A submodule's checked-out +as integrity evidence too, so a commit or staging transition requires the gate +to be rerun even when worktree bytes are restored. A submodule's checked-out materialized files are fingerprinted recursively, including nested dirty and non-ignored untracked content. An uninitialized submodule is bound to its -tracked gitlink. If checked content changes before verification or close, +tracked gitlink. If checked content or authoritative Git state changes before verification or close, AnchorLoop returns the task to review and requires a new pre-commit run. Fingerprint entries are length-framed and store a per-file content digest; a @@ -346,7 +350,7 @@ file. ### Evidence that expires when reality changes -AnchorLoop evidence graph showing brief, plan, and ruleset digests feeding approval, and precommit checks plus a workspace fingerprint gating verification and close. Changed artifacts or workspace state invalidate the next gate. +AnchorLoop evidence graph showing brief, plan, and ruleset digests feeding approval, and precommit checks plus a workspace fingerprint gating verification and close. Changed artifacts or workspace state invalidate the next gate. AnchorLoop records evidence, not identity: a changed approved artifact archives the approval, while changed checked code requires review and pre-commit again. diff --git a/SECURITY.md b/SECURITY.md index 7c5f3e4..50f01ea 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,15 @@ ## Supported versions -The published baseline is `anchorloop@0.1.0` (public alpha). Version `0.2.0` is an unreleased release -candidate. Security fixes are applied to the current development version; older snapshots are not maintained. +| Version | Supported | +|---|---| +| `0.2.x` | Unreleased release candidate | +| `0.1.x` | Security fixes only | + +Published production remains `anchorloop@0.1.0`. Version `0.2.0` is an +unreleased release candidate until the complete signed-tag and staged registry +workflow succeeds. New security work is developed on the current `0.2.x` +candidate; the published `0.1.x` line receives security fixes only. ## Reporting a vulnerability diff --git a/docs/ANCHOR_DECISION_MAP.md b/docs/ANCHOR_DECISION_MAP.md index 82e76ad..65d8f87 100644 --- a/docs/ANCHOR_DECISION_MAP.md +++ b/docs/ANCHOR_DECISION_MAP.md @@ -12,7 +12,7 @@ post-release product questions. | Skill distribution | Project or user scope across the six documented filesystem destinations; preview/apply and owned-file uninstall. Real-host discovery remains Experimental. | | Version source | `src/anchorloop/version.py`; Python metadata reads it and npm/tag values are checked against it. | | Mutation model | One interprocess lock plus validated redo journal and ordered event outbox for every project mutation. | -| Quality fingerprint | Materialized tracked/non-ignored files, recursively including submodules; Git metadata is diagnostic only. | +| Quality fingerprint | Materialized tracked/non-ignored files plus authoritative HEAD/index/diff state, recursively including submodules. | | Human modes | AUTO recommendation with FAST/STANDARD/CAREFUL, explicit downgrade reason, human artifact/comprehension, and CAREFUL delayed recall. | | Cache/recovery state | Ignored local runtime data; never committed as workflow evidence. | | Trust statement | `--by` and interactive TTY confirmation are auditable provenance, not authenticated identity. | @@ -21,14 +21,18 @@ post-release product questions. ### Publish the immutable 0.2.0 version -`anchorloop@0.1.0` is published production. Current `main` is the unreleased -`0.2.0` release candidate. A maintainer must merge the green release PR, create -a GitHub-verified signed annotated `v0.2.0` tag, and push only that tag. The -workflow then verifies the canonical version, refuses an existing exact npm -version, publishes through OIDC, runs the registry lifecycle, and checks npm -`gitHead` against the tagged commit. No code path or document may claim that -the `0.2.0` guided installer is available from npm `latest` before this gate -succeeds. +`anchorloop@0.1.0` remains published production. The current release branch is +the unreleased `0.2.0` release candidate. The release flow requires the signed +annotated tag commit to be contained in `origin/main` and to pass exact-tag CI +before the exact tarball is staged under `next` through a Trusted Publisher +configured for stage-only `npm stage publish`. A maintainer must download and +inspect the staged artifact, approve it with 2FA, then dispatch the read-only +exact-version registry lifecycle, verify that `next` points to that version, +and check npm `gitHead`. Only after those checks pass may a maintainer +interactively run `npm dist-tag add anchorloop@0.2.0 latest` with 2FA. The +workflow stores no npm token and never promotes `latest` automatically. Until +that sequence completes, no document may claim that `0.2.0` is published or +available from npm `latest`. ## Decisions before public beta diff --git a/docs/MIGRATION_0.2.md b/docs/MIGRATION_0.2.md index 8f4232f..8f136d9 100644 --- a/docs/MIGRATION_0.2.md +++ b/docs/MIGRATION_0.2.md @@ -3,11 +3,12 @@ ## Release status - Published production: `anchorloop@0.1.0` -- Current repository: unreleased `0.2.0` release candidate +- Current release branch: unreleased `0.2.0` release candidate -Do not run the registry commands in this guide until `anchorloop@0.2.0` exists -and its registry smoke has passed. Before publication, use the development -checkout procedure below. +Do not run the `0.2.0` registry commands in this guide until that exact version +exists publicly and its registry smoke has passed. Use exact versions +throughout the migration, do not replace `.anchor/`, and do not skip the +`0.1.0` recovery preflight. ## Compatibility contract @@ -26,20 +27,26 @@ The `0.2.0` setup path: - refuses to overwrite a locally modified installed skill unless `--force` is explicitly selected after reviewing the diff. -## Before publication: test the release candidate from a checkout +## Before upgrading: finish any 0.1.0 recovery -From the `release/0.2.0` checkout: +From each existing AnchorLoop project, first verify the old installation while +`0.1.0` still owns its recovery format: ~~~sh -python -m pip install -e . -anchor install --project --platform codex --apply -anchor add --apply -anchor doctor --strict +npx --yes anchorloop@0.1.0 doctor --strict ~~~ -The generated `.codex/skills/anchorloop/` directory is local dogfooding state -and must not be committed. Use `--platform agents` instead of `codex` when the -project consumes the cross-framework Agent Skills location. +If strict inspection reports pending recovery, repair it with `0.1.0` and then +repeat the strict check: + +~~~sh +npx --yes anchorloop@0.1.0 doctor --repair +npx --yes anchorloop@0.1.0 doctor --strict +~~~ + +Only continue after the final `0.1.0 doctor --strict` succeeds. Preserve its +output if recovery cannot complete; do not ask `0.2.0` to replay a partially +applied legacy journal. ## After publication: upgrade with the exact npm version @@ -52,7 +59,9 @@ npx --yes anchorloop@0.2.0 doctor --strict ~~~ Keep commands pinned to `@0.2.0` in automation and installed skill metadata. -Do not rely on npm `latest` during rollout. +The pin makes upgrades deliberate and avoids relying on npm `latest` during +the rollout. Before publication, test the candidate only from a development +checkout. ## Locally modified skill files @@ -83,6 +92,6 @@ npx --yes anchorloop@0.2.0 rules supersede \ Do not delete `.anchor/`, edit its JSON by hand, or downgrade state in place. Preserve the checkout and the failing `doctor --strict` output, inspect -transaction/recovery guidance with `status` and `doctor`, and fix the release -candidate before creating the signed tag. npm versions and published tags must +transaction/recovery guidance with `status` and the exact installed `doctor`, +and resolve the conflict before retrying. npm versions and published tags must never be overwritten. diff --git a/docs/PORTABLE_SKILL.md b/docs/PORTABLE_SKILL.md index fce93f7..b42efa0 100644 --- a/docs/PORTABLE_SKILL.md +++ b/docs/PORTABLE_SKILL.md @@ -6,9 +6,10 @@ engine and it never becomes the source of truth for task state. ## Install -Published production is `anchorloop@0.1.0`. Current `main` is the unreleased -`0.2.0` release candidate; its guided multi-agent installer is not available -from npm `latest` until the signed release workflow succeeds. +Published production is `anchorloop@0.1.0`. The current release branch is the +unreleased `0.2.0` release candidate; its guided multi-agent installer is not +available from npm `latest` until the signed tag, staging, approval, registry +smoke, and interactive promotion sequence succeeds. For production use, pin the published package explicitly: @@ -36,7 +37,7 @@ writes files. |---|---|---| | Filesystem destination/install matrix for `agents`, `codex`, `cursor`, `gemini`, `claude`, and `opencode` | **Verified** | Automated tests verify exact placement, owned assets, update, and uninstall behavior. This does not verify any host. | | Real-host skill discovery for every named host | **Experimental** | Each host must be opened and tested against its current release before discovery can be claimed. No host is marked Verified from filesystem placement alone. | -| Native adapters, hooks, MCP, and other undiscovered host integrations | **Planned** | These remain separate opt-in integrations and are not part of the `0.2.0` production claim. | +| Native adapters, hooks, MCP, and other undiscovered host integrations | **Planned** | These remain separate opt-in integrations and are not part of the `0.2.0` release-candidate scope. | Project setup uses `.agents/skills/anchorloop/`. Global setup writes the selected host's native directory, such as `~/.codex/skills/anchorloop/` or @@ -56,9 +57,9 @@ anchor install --global --all --apply anchor install --global --all ~~~ -After `0.2.0` is published, automation may use the equivalent exact-version -runner `npx --yes anchorloop@0.2.0 ...`. Keep release and automation commands -pinned even after npm `latest` moves. +After `0.2.0` is published and its registry smoke passes, automation may use +the exact-version runner `npx --yes anchorloop@0.2.0 ...`. Keep release and +automation commands pinned even after npm `latest` moves. For a project-scoped installation, AnchorLoop rejects symlink and Windows reparse-point components in `.codex/`, `.agents/`, `skills/`, and the skill diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 74318c3..9ba9799 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -79,9 +79,11 @@ required before approval identity can be treated as an access-control claim. relaxes the filesystem boundary. 9. Generated cache and recovery internals are ignored and never committed as project evidence. -10. The actual Git diff is re-evaluated before review and precommit; CAREFUL - paths cannot proceed under a lower mode without a reviewed, audited - path-specific override. +10. Changes from the task baseline are re-evaluated before review and + precommit: the actual Git diff when a committed `HEAD` exists, or a + deterministic materialized-file fallback for unborn and non-Git + workspaces. CAREFUL paths cannot proceed under a lower mode without a + reviewed, audited path-specific override. 11. Installer destination compatibility is not presented as verified native host discovery without a real-host test. @@ -98,11 +100,11 @@ anchor rules list|propose|approve|supersede anchor agent detect|setup|status ~~~ -After publication, the npm form runs the same surface through the pinned -`npx --yes anchorloop@0.2.0` command. During release-candidate development use -the editable checkout and `anchor`; `anchorloop@0.1.0` remains the production -npm package. README and the bundled skill contain the full structured -plan/verification examples. +After publication and registry smoke, the npm form will run the same surface +through the pinned `npx --yes anchorloop@0.2.0` command. During release-candidate +development use the editable Python checkout and `anchor`; `anchorloop@0.1.0` +remains the production npm package. README and the bundled skill contain the +full structured plan/verification examples. ## Production release gates @@ -114,18 +116,25 @@ Code is release-ready only when all of these are true: - wheel install and local packed-tarball lifecycle pass from a clean temp root; - the npm tarball contains every runtime module, skill asset, and README link target, with no cache, bytecode, build output, or project dependency; -- a GitHub-verified signed annotated tag points to the green commit; -- npm provenance publishing succeeds; +- a GitHub-verified signed annotated tag points to the green commit contained + in `origin/main`; +- stage-only npm Trusted Publishing places the exact tarball under `next` with + provenance and without a long-lived token; +- a maintainer downloads and inspects the staged artifact, then approves it + interactively with 2FA; - the exact public registry version passes install, full task lifecycle, - uninstall, and residue checks. - -The unscoped `anchorloop@0.1.0` package is already published. `0.2.0` must be -released only from a GitHub-verified signed annotated `v0.2.0` tag after the -full CI matrix passes. The protected `npm-release` environment and npm trusted -publisher for `.github/workflows/release.yml` must use OIDC without a -long-lived npm token. The workflow refuses an already-published exact version, -performs a registry-backed lifecycle smoke, and verifies that npm `gitHead` -matches the tagged commit. Do not create the tag or publish from a release PR. + uninstall, residue, `next` tag, and `gitHead` checks before a maintainer + promotes it to `latest` interactively with 2FA. + +The `0.2.0` release must come from a GitHub-verified signed annotated `v0.2.0` +tag contained in `origin/main` after the full CI matrix passes. The protected +`npm-release` environment must use a stage-only Trusted Publisher for +`.github/workflows/release.yml`; it allows `npm stage publish` through OIDC but +not direct `npm publish`. The automated workflow refuses an already-published +exact version and stores no npm token. Human-owned steps remain explicit: +inspect the staged artifact, approve it with 2FA, dispatch the read-only public +registry and `gitHead` verification, then promote the verified exact version to +`latest` interactively with 2FA. ## Pilot evidence diff --git a/docs/i18n/README.de.md b/docs/i18n/README.de.md index 7e7d443..71c2125 100644 --- a/docs/i18n/README.de.md +++ b/docs/i18n/README.de.md @@ -3,7 +3,7 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) > - **Veröffentlichte Produktion:** `anchorloop@0.1.0` -> - **Unveröffentlichtes main:** Release Candidate `0.2.0` +> - **Unveröffentlichter Release Candidate:** `0.2.0` > - Bis zur Veröffentlichung von `0.2.0` Produktionsbefehle mit > `npx --yes anchorloop@0.1.0 ...` ausführen. [Migrationsanleitung](../MIGRATION_0.2.md). diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index 4dc2715..650044c 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -3,7 +3,7 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) > - **Producción publicada:** `anchorloop@0.1.0` -> - **main sin publicar:** candidato de versión `0.2.0` +> - **Candidato de versión sin publicar:** `0.2.0` > - Hasta publicar `0.2.0`, usa `npx --yes anchorloop@0.1.0 ...` en producción. > [Guía de migración](../MIGRATION_0.2.md). diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index c0794ae..57b4dfd 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -3,7 +3,7 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) > - **Production publiée :** `anchorloop@0.1.0` -> - **main non publié :** release candidate `0.2.0` +> - **Release candidate non publié :** `0.2.0` > - Jusqu'à la publication de `0.2.0`, utilisez > `npx --yes anchorloop@0.1.0 ...` en production. [Guide de migration](../MIGRATION_0.2.md). diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index 32b882a..9125eda 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -3,7 +3,7 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) > - **公開済み production:** `anchorloop@0.1.0` -> - **未公開の main:** `0.2.0` release candidate +> - **未公開の release candidate:** `0.2.0` > - `0.2.0` が公開されるまでは production で > `npx --yes anchorloop@0.1.0 ...` を使用してください。[移行ガイド](../MIGRATION_0.2.md)。 diff --git a/docs/i18n/README.pt-BR.md b/docs/i18n/README.pt-BR.md index a2ad1ac..a8c5af7 100644 --- a/docs/i18n/README.pt-BR.md +++ b/docs/i18n/README.pt-BR.md @@ -3,7 +3,7 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) > - **Produção publicada:** `anchorloop@0.1.0` -> - **main não publicado:** release candidate `0.2.0` +> - **Release candidate não publicado:** `0.2.0` > - Até a publicação de `0.2.0`, use `npx --yes anchorloop@0.1.0 ...` em > produção. [Guia de migração](../MIGRATION_0.2.md). diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index 79cd0b2..6791530 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -1,7 +1,7 @@ # AnchorLoop > - **Опубликованный production:** `anchorloop@0.1.0` -> - **Неопубликованный main:** release candidate `0.2.0` +> - **Неопубликованный release candidate:** `0.2.0` > - До публикации `0.2.0` используйте в production точную команду > `npx --yes anchorloop@0.1.0 ...`. [Инструкция по миграции](../MIGRATION_0.2.md). diff --git a/docs/i18n/README.zh-CN.md b/docs/i18n/README.zh-CN.md index 569b417..1a58239 100644 --- a/docs/i18n/README.zh-CN.md +++ b/docs/i18n/README.zh-CN.md @@ -3,7 +3,7 @@ [English](../../README.md) · [Русский](README.ru.md) · [Español](README.es.md) · [Português](README.pt-BR.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [日本語](README.ja.md) · [简体中文](README.zh-CN.md) > - **已发布的 production:** `anchorloop@0.1.0` -> - **尚未发布的 main:** `0.2.0` release candidate +> - **尚未发布的 release candidate:** `0.2.0` > - 在 `0.2.0` 发布前,production 请使用 > `npx --yes anchorloop@0.1.0 ...`。[迁移指南](../MIGRATION_0.2.md)。 diff --git a/npm/scripts/finalize-release-docs.js b/npm/scripts/finalize-release-docs.js new file mode 100644 index 0000000..b68370d --- /dev/null +++ b/npm/scripts/finalize-release-docs.js @@ -0,0 +1,777 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const SUPPORTED_VERSION = "0.2.0"; +const PREVIOUS_PRODUCTION_VERSION = "0.1.0"; + +function lines(...values) { + return values.join("\n"); +} + +function normalizeNewlines(value) { + return value.replace(/\r\n/g, "\n"); +} + +function assertReleaseDate(releaseDate) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(releaseDate || "")) { + throw new Error("Release artifact date must use YYYY-MM-DD from the tagged commit."); + } + const parsed = new Date(`${releaseDate}T00:00:00Z`); + if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== releaseDate) { + throw new Error(`Release artifact date is invalid: ${releaseDate}.`); + } +} + +function countOccurrences(value, needle) { + if (!needle) { + throw new Error("Release documentation replacement cannot use an empty source fragment."); + } + return value.split(needle).length - 1; +} + +function replaceExactlyOnce(value, source, replacement, relativePath) { + const count = countOccurrences(value, source); + if (count !== 1) { + throw new Error( + `${relativePath}: expected the release-candidate source fragment exactly once, found ${count}.`, + ); + } + return value.replace(source, replacement); +} + +function translationSpecs(version, releaseDate) { + assertReleaseDate(releaseDate); + const exactPackage = `anchorloop@${version}`; + return { + "docs/i18n/README.de.md": { + source: lines( + "> - **Veröffentlichte Produktion:** `anchorloop@0.1.0`", + "> - **Unveröffentlichter Release Candidate:** `0.2.0`", + "> - Bis zur Veröffentlichung von `0.2.0` Produktionsbefehle mit", + "> `npx --yes anchorloop@0.1.0 ...` ausführen. [Migrationsanleitung](../MIGRATION_0.2.md).", + ), + artifact: lines( + `> - **Release-Artefakt:** \`${exactPackage}\``, + `> - **Artefaktdatum:** \`${releaseDate}\``, + "> - **Registry-/Dist-Tag-Status:** wird von diesem unveränderlichen Artefakt nicht festgelegt.", + "> - Verwenden Sie die exakte Version erst nach externer Registry-Prüfung. [Migrationsanleitung](../MIGRATION_0.2.md).", + ), + }, + "docs/i18n/README.es.md": { + source: lines( + "> - **Producción publicada:** `anchorloop@0.1.0`", + "> - **Candidato de versión sin publicar:** `0.2.0`", + "> - Hasta publicar `0.2.0`, usa `npx --yes anchorloop@0.1.0 ...` en producción.", + "> [Guía de migración](../MIGRATION_0.2.md).", + ), + artifact: lines( + `> - **Artefacto de versión:** \`${exactPackage}\``, + `> - **Fecha del artefacto:** \`${releaseDate}\``, + "> - **Estado del registro/dist-tag:** este artefacto inmutable no lo declara.", + "> - Usa la versión exacta solo después de verificar el registro externamente. [Guía de migración](../MIGRATION_0.2.md).", + ), + }, + "docs/i18n/README.fr.md": { + source: lines( + "> - **Production publiée :** `anchorloop@0.1.0`", + "> - **Release candidate non publié :** `0.2.0`", + "> - Jusqu'à la publication de `0.2.0`, utilisez", + "> `npx --yes anchorloop@0.1.0 ...` en production. [Guide de migration](../MIGRATION_0.2.md).", + ), + artifact: lines( + `> - **Artefact de version :** \`${exactPackage}\``, + `> - **Date de l'artefact :** \`${releaseDate}\``, + "> - **État du registre/dist-tag :** cet artefact immuable ne l'affirme pas.", + "> - Utilisez la version exacte uniquement après vérification externe du registre. [Guide de migration](../MIGRATION_0.2.md).", + ), + }, + "docs/i18n/README.ja.md": { + source: lines( + "> - **公開済み production:** `anchorloop@0.1.0`", + "> - **未公開の release candidate:** `0.2.0`", + "> - `0.2.0` が公開されるまでは production で", + "> `npx --yes anchorloop@0.1.0 ...` を使用してください。[移行ガイド](../MIGRATION_0.2.md)。", + ), + artifact: lines( + `> - **リリース成果物:** \`${exactPackage}\``, + `> - **成果物の日付:** \`${releaseDate}\``, + "> - **レジストリ/dist-tag の状態:** この不変の成果物では表明しません。", + "> - 外部でレジストリを確認した後にのみ正確なバージョンを使用してください。[移行ガイド](../MIGRATION_0.2.md)。", + ), + }, + "docs/i18n/README.pt-BR.md": { + source: lines( + "> - **Produção publicada:** `anchorloop@0.1.0`", + "> - **Release candidate não publicado:** `0.2.0`", + "> - Até a publicação de `0.2.0`, use `npx --yes anchorloop@0.1.0 ...` em", + "> produção. [Guia de migração](../MIGRATION_0.2.md).", + ), + artifact: lines( + `> - **Artefato de release:** \`${exactPackage}\``, + `> - **Data do artefato:** \`${releaseDate}\``, + "> - **Estado do registry/dist-tag:** este artefato imutável não o declara.", + "> - Use a versão exata somente após verificar o registry externamente. [Guia de migração](../MIGRATION_0.2.md).", + ), + }, + "docs/i18n/README.ru.md": { + source: lines( + "> - **Опубликованный production:** `anchorloop@0.1.0`", + "> - **Неопубликованный release candidate:** `0.2.0`", + "> - До публикации `0.2.0` используйте в production точную команду", + "> `npx --yes anchorloop@0.1.0 ...`. [Инструкция по миграции](../MIGRATION_0.2.md).", + ), + artifact: lines( + `> - **Артефакт релиза:** \`${exactPackage}\``, + `> - **Дата артефакта:** \`${releaseDate}\``, + "> - **Состояние registry/dist-tag:** этот неизменяемый артефакт его не утверждает.", + "> - Используйте точную версию только после внешней проверки registry. [Инструкция по миграции](../MIGRATION_0.2.md).", + ), + extraReplacements: [ + { + source: "Основной английский README содержит актуальную полную документацию, режимы FAST/STANDARD/CAREFUL, безопасную установку skill, транзакционное восстановление и ограничения release candidate.", + artifact: "Основной английский README содержит актуальную полную документацию, режимы FAST/STANDARD/CAREFUL, безопасную установку skill, транзакционное восстановление и границы артефакта релиза.", + }, + ], + }, + "docs/i18n/README.zh-CN.md": { + source: lines( + "> - **已发布的 production:** `anchorloop@0.1.0`", + "> - **尚未发布的 release candidate:** `0.2.0`", + "> - 在 `0.2.0` 发布前,production 请使用", + "> `npx --yes anchorloop@0.1.0 ...`。[迁移指南](../MIGRATION_0.2.md)。", + ), + artifact: lines( + `> - **发布制品:** \`${exactPackage}\``, + `> - **制品日期:** \`${releaseDate}\``, + "> - **Registry/dist-tag 状态:** 此不可变制品不对此作出声明。", + "> - 仅在外部验证 registry 后使用精确版本。[迁移指南](../MIGRATION_0.2.md)。", + ), + }, + }; +} + +function releaseDocumentSpecs(version, releaseDate) { + assertReleaseDate(releaseDate); + if (version !== SUPPORTED_VERSION) { + throw new Error( + `Release documentation finalizer supports ${SUPPORTED_VERSION}; update its exact transformations before releasing ${version}.`, + ); + } + const exactPackage = `anchorloop@${version}`; + const previousPackage = `anchorloop@${PREVIOUS_PRODUCTION_VERSION}`; + const specs = [ + { + path: "README.md", + replacements: [ + { + source: lines( + `**Published production:** \`${previousPackage}\``, + "", + `**Unreleased main:** \`${version}\` release candidate`, + "", + `The published \`${PREVIOUS_PRODUCTION_VERSION}\` package remains the production version. The current`, + "release branch contains the unreleased recovery, validation, ownership,", + `release-safety, and multi-agent installer work planned for \`${version}\`. Until the`, + `signed \`v${version}\` tag passes staging, maintainer approval, exact-version registry`, + "smoke, and interactive `latest` promotion, do not describe those additions as", + "available from npm `latest`.", + ), + artifact: lines( + `**Release artifact:** \`${exactPackage}\``, + "", + `**Artifact date:** \`${releaseDate}\``, + "", + "**Registry state:** verify the exact version and active dist-tags externally.", + "", + `This immutable artifact contains the recovery, validation, ownership, release-safety,`, + `and multi-agent installer work for \`${version}\`. It does not assert registry`, + "availability or which dist-tag points to the version. Verify registry state before", + "installation and keep automation pinned to the exact version.", + ), + }, + { + source: `## What is implemented in the unreleased ${version} candidate`, + artifact: `## What is included in the ${version} release artifact`, + }, + { + source: "docs/assets/anchorloop-delivery-loop.svg", + artifact: `https://raw.githubusercontent.com/ppmarkek/AnchorLoop/v${version}/docs/assets/anchorloop-delivery-loop.svg`, + }, + { + source: "docs/assets/anchorloop-evidence-integrity.svg", + artifact: `https://raw.githubusercontent.com/ppmarkek/AnchorLoop/v${version}/docs/assets/anchorloop-evidence-integrity.svg`, + }, + { + source: lines( + `## Install the published ${PREVIOUS_PRODUCTION_VERSION} package`, + "", + "Requirements: Node.js 18 or newer and Python 3.11 or newer.", + "", + "Use the exact published production version:", + "", + "~~~sh", + `npx --yes ${previousPackage} install --project --platform codex --apply`, + "~~~", + "", + "Do not use an unversioned `npx anchorloop install` command to test the features", + "documented below: npm `latest` continues to resolve to `0.1.0` until the", + `\`${version}\` release flow completes.`, + "", + `## Unreleased ${version} guided setup`, + "", + "From a development checkout, install the current Python CLI in editable mode:", + "", + "~~~sh", + "python -m pip install -e .", + "anchor install --interactive", + "~~~", + ), + artifact: lines( + `## Install the exact ${version} release artifact`, + "", + "Requirements: Node.js 18 or newer and Python 3.11 or newer.", + "", + "Use the exact artifact version only after external registry verification:", + "", + "~~~sh", + `npx --yes ${exactPackage} install --project --platform codex --apply`, + "~~~", + "", + "Do not use an unversioned `npx anchorloop install` command in automation; keep the", + "exact version in commands and installed skill metadata.", + "", + `## ${version} guided setup`, + "", + "Run the compact setup wizard from the exact version after registry verification:", + "", + "~~~sh", + `npx --yes ${exactPackage} install --interactive`, + "~~~", + ), + }, + { + source: "pipx install git+https://github.com/ppmarkek/AnchorLoop.git", + artifact: `pipx install "git+https://github.com/ppmarkek/AnchorLoop.git@v${version}"`, + }, + { + source: 'python -m pip install "git+https://github.com/ppmarkek/AnchorLoop.git"', + artifact: `python -m pip install "git+https://github.com/ppmarkek/AnchorLoop.git@v${version}"`, + }, + { + source: lines( + `See the [${PREVIOUS_PRODUCTION_VERSION} to ${version} migration guide](docs/MIGRATION_0.2.md) for the required`, + `\`${PREVIOUS_PRODUCTION_VERSION}\` recovery preflight, release-candidate procedure, and exact commands to`, + "use after publication.", + ), + artifact: lines( + `See the [${PREVIOUS_PRODUCTION_VERSION} to ${version} migration guide](docs/MIGRATION_0.2.md) for the required`, + `\`${PREVIOUS_PRODUCTION_VERSION}\` recovery preflight and the exact-version upgrade procedure.`, + ), + }, + ], + }, + { + path: "CHANGELOG.md", + replacements: [ + { + source: lines( + "All notable AnchorLoop changes are documented here. npm releases are immutable;", + "an entry marked **Unreleased** describes repository state, not production npm", + "availability.", + "", + `## ${version} - Unreleased`, + "", + `Published production remains \`${previousPackage}\` until the signed \`v${version}\` tag`, + "is contained in `main` and passes staging, maintainer approval, exact-version", + "registry smoke, and interactive `latest` promotion.", + ), + artifact: lines( + "All notable AnchorLoop changes are documented here. This immutable package", + `artifact records the changes included in version \`${version}\`; registry availability`, + "and active dist-tags are external state and must be verified separately.", + "", + `## ${version} - ${releaseDate}`, + ), + }, + ], + }, + { + path: "SECURITY.md", + replacements: [ + { + source: lines( + "| Version | Supported |", + "|---|---|", + "| `0.2.x` | Unreleased release candidate |", + "| `0.1.x` | Security fixes only |", + "", + `Published production remains \`${previousPackage}\`. Version \`${version}\` is an`, + "unreleased release candidate until the complete signed-tag and staged registry", + "workflow succeeds. New security work is developed on the current `0.2.x`", + "candidate; the published `0.1.x` line receives security fixes only.", + ), + artifact: lines( + "| Version | Artifact relationship |", + "|---|---|", + "| `0.2.x` | Line represented by this artifact |", + "| `0.1.x` | Previous artifact line |", + "", + `This immutable document describes the security scope of the \`${exactPackage}\` release`, + `artifact dated \`${releaseDate}\`. Registry availability, active dist-tags, and live support policy are`, + "external state; consult the repository security policy before deployment.", + ), + }, + ], + }, + { + path: "CONTRIBUTING.md", + replacements: [ + { + source: lines( + `\`${previousPackage}\` is the published public-alpha baseline; current \`main\` is`, + `the unreleased \`${version}\` release candidate. Small, testable changes that`, + "strengthen its local, agent-neutral workflow core are preferred over broad", + "integrations.", + ), + artifact: lines( + `\`${exactPackage}\` is the exact release artifact dated \`${releaseDate}\` represented by this source snapshot.`, + "Small, testable changes that strengthen its local, agent-neutral workflow core are", + "preferred over broad integrations.", + ), + }, + { + source: lines( + "The optional exact-version npm route (for example,", + `\`npx --yes ${exactPackage} install\` after publication) requires Node.js 18 or`, + "newer and must remain a thin launcher around the Python core. During candidate", + "development exercise the checkout directly. When changing its package,", + "launcher, or skill templates:", + ), + artifact: lines( + "The optional exact-version npm route (for example,", + `\`npx --yes ${exactPackage} install\`) requires Node.js 18 or newer and must remain`, + "a thin launcher around the Python core. Exercise development changes from a checkout.", + "When changing its package, launcher, or skill templates:", + ), + }, + { + source: lines( + `\`${previousPackage}\` remains the published production baseline while \`${version}\` is`, + `an unreleased candidate. npm versions are immutable: before creating \`v${version}\`,`, + `verify that \`${exactPackage}\` does not exist. The signed-tag workflow runs`, + "exact-tag CI against the truthful repository docs, then changes only packaged", + "documentation in its disposable job using the tagged commit date. It dry-runs", + "package assembly with lifecycle scripts disabled, then stages the finalized Git", + "checkout directory under `next` with lifecycle scripts disabled through", + "stage-only OIDC so npm records `gitHead`. A maintainer downloads and inspects", + "the exact staged tarball, approves it with 2FA, dispatches the", + "tag-bound exact registry smoke, verifies npm `gitHead`, and only then promotes", + "the version to `latest` manually with 2FA. After", + "`npm view anchorloop@latest version` returns", + `\`${version}\`, open a post-promotion documentation PR against \`main\`. That PR must`, + `move every source status surface and its release-document tests from \`${PREVIOUS_PRODUCTION_VERSION}\``, + `production / \`${version}\` candidate to \`${version}\` production. It must also update or`, + `retire the \`${version}\`-specific finalizer source fragments and source-RC assertions`, + `before preparing the next release cycle. Leave the signed \`v${version}\` artifact`, + `unchanged. Never publish \`${version}\` directly, move a signed tag, or overwrite a`, + "failed release. Fix pre-stage failures in a new commit; after approval,", + "deprecate a defective version and release `0.2.1`. Do not weaken `release.yml`", + "or add a long-lived token.", + ), + artifact: lines( + `This source snapshot represents the immutable \`${exactPackage}\` artifact dated`, + `\`${releaseDate}\`; registry availability and active dist-tags are external state. npm`, + "versions are immutable. The release workflow runs exact-tag CI on the truthful", + "repository docs, prepares only packaged documentation in a disposable job using", + "the tagged commit date, dry-runs package assembly with lifecycle scripts disabled,", + "and stages the finalized Git checkout directory under `next` with lifecycle scripts", + "disabled through stage-only OIDC so npm records `gitHead`.", + "A maintainer downloads, inspects, and approves the exact staged artifact with 2FA,", + "then dispatches tag-bound exact", + "registry smoke, verifies npm `gitHead`, and promotes the exact version manually.", + "After external `latest` promotion is verified, a maintainer opens a", + "post-promotion documentation PR against `main`, updates or retires the", + "release-specific finalizer fragments and source-RC assertions, and leaves the", + "signed artifact unchanged.", + "Never bypass the staged flow, move a signed tag, or overwrite a failed release.", + "After approval, deprecate a defective version and release a new patch. Do not", + "weaken `release.yml` or add a long-lived token.", + ), + }, + ], + }, + { + path: "docs/MIGRATION_0.2.md", + replacements: [ + { + source: lines( + "## Release status", + "", + `- Published production: \`${previousPackage}\``, + `- Current release branch: unreleased \`${version}\` release candidate`, + "", + `Do not run the \`${version}\` registry commands in this guide until that exact version`, + "exists publicly and its registry smoke has passed. Use exact versions", + "throughout the migration, do not replace `.anchor/`, and do not skip the", + `\`${PREVIOUS_PRODUCTION_VERSION}\` recovery preflight.`, + ), + artifact: lines( + "## Release artifact", + "", + `- Exact release artifact: \`${exactPackage}\``, + `- Artifact date: \`${releaseDate}\``, + "- Registry and dist-tag status: verify externally", + "", + `Run the \`${version}\` registry commands only after that exact version is externally`, + "available and its registry smoke has passed. Use exact versions throughout the", + "migration, do not replace `.anchor/`, and do not skip the", + `\`${PREVIOUS_PRODUCTION_VERSION}\` recovery preflight.`, + ), + }, + { + source: "## After publication: upgrade with the exact npm version", + artifact: "## Upgrade with the exact npm version", + }, + { + source: lines( + `Keep commands pinned to \`@${version}\` in automation and installed skill metadata.`, + "The pin makes upgrades deliberate and avoids relying on npm `latest` during", + "the rollout. Before publication, test the candidate only from a development", + "checkout.", + ), + artifact: lines( + `Keep commands pinned to \`@${version}\` in automation and installed skill metadata.`, + "The pin makes upgrades deliberate and avoids relying on mutable dist-tags. Use these", + "commands only after the exact registry version and its smoke test are verified;", + "otherwise exercise a development checkout.", + ), + }, + ], + }, + { + path: "docs/ANCHOR_DECISION_MAP.md", + replacements: [ + { + source: lines( + `\`${previousPackage}\` remains published production. The current release branch is`, + `the unreleased \`${version}\` release candidate. The release flow requires the signed`, + "annotated tag commit to be contained in `origin/main` and to pass exact-tag CI", + "before the exact tarball is staged under `next` through a Trusted Publisher", + "configured for stage-only `npm stage publish`. A maintainer must download and", + "inspect the staged artifact, approve it with 2FA, then dispatch the read-only", + "exact-version registry lifecycle, verify that `next` points to that version,", + "and check npm `gitHead`. Only after those checks pass may a maintainer", + `interactively run \`npm dist-tag add ${exactPackage} latest\` with 2FA. The`, + "workflow stores no npm token and never promotes `latest` automatically. Until", + `that sequence completes, no document may claim that \`${version}\` is published or`, + "available from npm `latest`.", + ), + artifact: lines( + `This document describes the immutable \`${exactPackage}\` release artifact dated`, + `\`${releaseDate}\`. Registry`, + "availability and active dist-tags are intentionally external state. The release flow", + "requires the signed annotated tag commit to be contained in `origin/main` and to", + "pass exact-tag CI before the exact tarball is staged under `next` through a Trusted", + "Publisher configured for stage-only `npm stage publish`. A maintainer must download", + "and inspect the staged artifact, approve it with 2FA, then dispatch the read-only", + "exact-version registry lifecycle, verify that `next` points to that version, and", + "check npm `gitHead`. Only after those checks pass may a maintainer promote the exact", + "version interactively with 2FA. The workflow stores no npm token and never promotes", + "a dist-tag automatically; consumers must verify registry state externally.", + ), + }, + ], + }, + { + path: "docs/PORTABLE_SKILL.md", + replacements: [ + { + source: lines( + `Published production is \`${previousPackage}\`. The current release branch is the`, + `unreleased \`${version}\` release candidate; its guided multi-agent installer is not`, + "available from npm `latest` until the signed tag, staging, approval, registry", + "smoke, and interactive promotion sequence succeeds.", + "", + "For production use, pin the published package explicitly:", + "", + "~~~powershell", + `npx --yes ${previousPackage} install --project --platform codex --apply`, + "~~~", + "", + `To exercise the \`${version}\` candidate from a checkout, install the current source`, + "and open the guided installer locally:", + "", + "~~~powershell", + "python -m pip install -e .", + "anchor install --interactive", + "~~~", + ), + artifact: lines( + `This document is packaged with the exact \`${exactPackage}\` release artifact dated`, + `\`${releaseDate}\`. It`, + "does not assert registry availability or active dist-tags; verify both externally.", + "", + "Use the exact package version only after registry verification:", + "", + "~~~powershell", + `npx --yes ${exactPackage} install --project --platform codex --apply`, + "~~~", + "", + "Open the guided installer from the exact version:", + "", + "~~~powershell", + `npx --yes ${exactPackage} install --interactive`, + "~~~", + ), + }, + { + source: `These remain separate opt-in integrations and are not part of the \`${version}\` release-candidate scope.`, + artifact: `These remain separate opt-in integrations and are not part of the \`${version}\` release-artifact scope.`, + }, + { + source: lines( + `After \`${version}\` is published and its registry smoke passes, automation may use`, + `the exact-version runner \`npx --yes ${exactPackage} ...\`. Keep release and`, + "automation commands pinned even after npm `latest` moves.", + ), + artifact: lines( + "When the exact registry version is available and its smoke test passes, automation", + `may use the runner \`npx --yes ${exactPackage} ...\`. Keep release and automation`, + "commands pinned instead of relying on mutable dist-tags.", + ), + }, + ], + }, + { + path: "docs/PROJECT_PLAN.md", + replacements: [ + { + source: `Status: \`${version}\` release candidate; \`${PREVIOUS_PRODUCTION_VERSION}\` is published production`, + artifact: `Status: immutable \`${version}\` release artifact dated \`${releaseDate}\`; registry and dist-tag state is external`, + }, + { + source: "## What the 0.2 candidate implements", + artifact: "## What the 0.2 release artifact implements", + }, + { + source: lines( + "After publication and registry smoke, the npm form will run the same surface", + `through the pinned \`npx --yes ${exactPackage}\` command. During release-candidate`, + `development use the editable Python checkout and \`anchor\`; \`${previousPackage}\``, + "remains the production npm package. README and the bundled skill contain the", + "full structured plan/verification examples.", + ), + artifact: lines( + "When the exact registry version is externally available and its smoke test passes,", + `the npm form runs the same surface through the pinned \`npx --yes ${exactPackage}\``, + "command. Development checkouts may use the editable Python installation and `anchor`.", + "README and the bundled skill contain the full structured plan/verification examples.", + ), + }, + ], + }, + ]; + + for (const [relativePath, translation] of Object.entries(translationSpecs(version, releaseDate))) { + specs.push({ + path: relativePath, + replacements: [ + { source: translation.source, artifact: translation.artifact }, + ...(translation.extraReplacements || []), + ], + }); + } + return specs; +} + +function discoverTranslationPaths(root) { + const directory = path.join(root, "docs", "i18n"); + return fs + .readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^README\..+\.md$/.test(entry.name)) + .map((entry) => `docs/i18n/${entry.name}`) + .sort(); +} + +function assertTranslationCoverage(root, version, releaseDate) { + const expected = Object.keys(translationSpecs(version, releaseDate)).sort(); + const discovered = discoverTranslationPaths(root); + if (JSON.stringify(discovered) !== JSON.stringify(expected)) { + throw new Error( + `Release documentation finalizer translation set is stale. Expected ${expected.join(", ")}; found ${discovered.join(", ")}.`, + ); + } +} + +function buildArtifactContents(root, version, releaseDate) { + assertTranslationCoverage(root, version, releaseDate); + const outputs = new Map(); + for (const spec of releaseDocumentSpecs(version, releaseDate)) { + const absolutePath = path.join(root, ...spec.path.split("/")); + let content = normalizeNewlines(fs.readFileSync(absolutePath, "utf8")); + for (const replacement of spec.replacements) { + content = replaceExactlyOnce(content, replacement.source, replacement.artifact, spec.path); + } + outputs.set(spec.path, content); + } + assertArtifactContents(outputs, version, releaseDate); + return outputs; +} + +function readArtifactContents(root, version, releaseDate) { + assertTranslationCoverage(root, version, releaseDate); + const outputs = new Map(); + for (const spec of releaseDocumentSpecs(version, releaseDate)) { + const absolutePath = path.join(root, ...spec.path.split("/")); + outputs.set(spec.path, normalizeNewlines(fs.readFileSync(absolutePath, "utf8"))); + } + return outputs; +} + +function assertIncludes(contents, relativePath, marker) { + const content = contents.get(relativePath); + if (!content || !content.includes(marker)) { + throw new Error(`${relativePath}: release artifact marker is missing: ${marker}`); + } +} + +function assertArtifactContents(contents, version, releaseDate) { + assertReleaseDate(releaseDate); + const exactPackage = `anchorloop@${version}`; + const required = new Map([ + ["README.md", `**Release artifact:** \`${exactPackage}\``], + ["CHANGELOG.md", `## ${version} - ${releaseDate}\n`], + ["SECURITY.md", `security scope of the \`${exactPackage}\` release`], + ["CONTRIBUTING.md", `\`${exactPackage}\` is the exact release artifact dated \`${releaseDate}\``], + ["docs/MIGRATION_0.2.md", `- Exact release artifact: \`${exactPackage}\``], + ["docs/ANCHOR_DECISION_MAP.md", `immutable \`${exactPackage}\` release artifact`], + ["docs/PORTABLE_SKILL.md", `exact \`${exactPackage}\` release artifact`], + ["docs/PROJECT_PLAN.md", `Status: immutable \`${version}\` release artifact`], + ]); + for (const [relativePath, marker] of required) { + assertIncludes(contents, relativePath, marker); + assertIncludes(contents, relativePath, releaseDate); + } + + const forbidden = [ + /unreleased/i, + /release[- ]candidate/i, + /published production/i, + /latest stable/i, + /current release branch/i, + /npm `latest` (?:continues to resolve|resolves)/i, + /available from npm `latest`/i, + /publishes it with npm provenance through OIDC/i, + /exact-source/i, + ]; + for (const [relativePath, content] of contents) { + for (const pattern of forbidden) { + if (pattern.test(content)) { + throw new Error(`${relativePath}: release artifact retains forbidden status marker ${pattern}.`); + } + } + } + + const readme = contents.get("README.md"); + if (readme.includes(`npx --yes anchorloop@${PREVIOUS_PRODUCTION_VERSION} install`)) { + throw new Error("README.md: release artifact still installs the previous production version."); + } + if (readme.includes("raw.githubusercontent.com/ppmarkek/AnchorLoop/main/")) { + throw new Error("README.md: release artifact retains a mutable main-branch image URL."); + } + for (const asset of ["anchorloop-delivery-loop.svg", "anchorloop-evidence-integrity.svg"]) { + const pinned = `https://raw.githubusercontent.com/ppmarkek/AnchorLoop/v${version}/docs/assets/${asset}`; + if (!readme.includes(pinned)) { + throw new Error(`README.md: release artifact lacks pinned image URL ${pinned}.`); + } + } + const changelog = contents.get("CHANGELOG.md"); + if (!changelog.includes(`## ${version} - ${releaseDate}`)) { + throw new Error("CHANGELOG.md: release artifact heading must use the tagged commit date."); + } + + for (const relativePath of Object.keys(translationSpecs(version, releaseDate))) { + const banner = contents.get(relativePath).split("\n").slice(0, 10).join("\n"); + if (!banner.includes(`\`${exactPackage}\``)) { + throw new Error(`${relativePath}: release artifact banner lacks ${exactPackage}.`); + } + if (banner.includes(`anchorloop@${PREVIOUS_PRODUCTION_VERSION}`)) { + throw new Error(`${relativePath}: release artifact banner retains the previous version.`); + } + if (!banner.includes(`\`${releaseDate}\``)) { + throw new Error(`${relativePath}: release artifact banner lacks the tagged commit date.`); + } + const dates = banner.match(/\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b/g) || []; + if (dates.length !== 1 || dates[0] !== releaseDate) { + throw new Error(`${relativePath}: release artifact banner contains an unexpected release date.`); + } + } +} + +function finalizeReleaseDocs(root, version, releaseDate) { + const outputs = buildArtifactContents(root, version, releaseDate); + for (const [relativePath, content] of outputs) { + const absolutePath = path.join(root, ...relativePath.split("/")); + fs.writeFileSync(absolutePath, content, "utf8"); + } + return [...outputs.keys()]; +} + +function assertReleaseArtifactDocs(root, version, releaseDate) { + const contents = readArtifactContents(root, version, releaseDate); + assertArtifactContents(contents, version, releaseDate); + return [...contents.keys()]; +} + +function parseArguments(argv) { + const result = { check: false, releaseDate: null, root: process.cwd(), version: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--check") { + result.check = true; + } else if (argument === "--root" || argument === "--version" || argument === "--release-date") { + const value = argv[index + 1]; + if (!value) { + throw new Error(`${argument} requires a value.`); + } + const key = argument === "--release-date" ? "releaseDate" : argument.slice(2); + result[key] = value; + index += 1; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + result.root = path.resolve(result.root); + if (!result.version) { + const packageMetadata = JSON.parse(fs.readFileSync(path.join(result.root, "package.json"), "utf8")); + result.version = packageMetadata.version; + } + assertReleaseDate(result.releaseDate); + return result; +} + +if (require.main === module) { + try { + const options = parseArguments(process.argv.slice(2)); + const files = options.check + ? assertReleaseArtifactDocs(options.root, options.version, options.releaseDate) + : finalizeReleaseDocs(options.root, options.version, options.releaseDate); + process.stdout.write( + `${options.check ? "Verified" : "Prepared"} ${files.length} release artifact documents for ${options.version}.\n`, + ); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +module.exports = { + SUPPORTED_VERSION, + assertReleaseArtifactDocs, + buildArtifactContents, + finalizeReleaseDocs, + parseArguments, + releaseDocumentSpecs, +}; diff --git a/npm/test/anchorloop.test.js b/npm/test/anchorloop.test.js index 0d0b802..bf3f8fd 100644 --- a/npm/test/anchorloop.test.js +++ b/npm/test/anchorloop.test.js @@ -196,12 +196,13 @@ test("the package manifest allowlists the runner and its Python skill assets", ( const readme = fs.readFileSync(path.join(repositoryRoot, "README.md"), "utf8"); assert.match( readme, - /https:\/\/raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/main\/docs\/assets\/anchorloop-delivery-loop\.svg/, + /src="docs\/assets\/anchorloop-delivery-loop\.svg"/, ); assert.match( readme, - /https:\/\/raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/main\/docs\/assets\/anchorloop-evidence-integrity\.svg/, + /src="docs\/assets\/anchorloop-evidence-integrity\.svg"/, ); + assert.doesNotMatch(readme, /raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/main\//); }); test("the actual npm tarball contains only release runtime and documentation files", { timeout: 60_000 }, () => { diff --git a/npm/test/release-docs.test.js b/npm/test/release-docs.test.js new file mode 100644 index 0000000..a3b131b --- /dev/null +++ b/npm/test/release-docs.test.js @@ -0,0 +1,436 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const zlib = require("node:zlib"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const { + SUPPORTED_VERSION, + assertReleaseArtifactDocs, + finalizeReleaseDocs, + releaseDocumentSpecs, +} = require("../scripts/finalize-release-docs.js"); + +const RELEASE_DATE = "2026-07-15"; + +function fixtureRoot() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-release-docs-")); + for (const spec of releaseDocumentSpecs(SUPPORTED_VERSION, RELEASE_DATE)) { + const source = path.join(repositoryRoot, ...spec.path.split("/")); + const destination = path.join(root, ...spec.path.split("/")); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + } + const runtimeSource = path.join(repositoryRoot, "src", "anchorloop", "version.py"); + const runtimeDestination = path.join(root, "src", "anchorloop", "version.py"); + fs.mkdirSync(path.dirname(runtimeDestination), { recursive: true }); + fs.copyFileSync(runtimeSource, runtimeDestination); + return root; +} + +function readDocuments(root) { + return new Map( + releaseDocumentSpecs(SUPPORTED_VERSION, RELEASE_DATE).map((spec) => [ + spec.path, + fs.readFileSync(path.join(root, ...spec.path.split("/")), "utf8"), + ]), + ); +} + +function copyPackageFixture(root) { + fs.mkdirSync(root, { recursive: true }); + const packageMetadata = JSON.parse( + fs.readFileSync(path.join(repositoryRoot, "package.json"), "utf8"), + ); + const fixturePaths = new Set([ + "package.json", + "package-lock.json", + "npm/scripts/finalize-release-docs.js", + ...packageMetadata.files.map((relativePath) => relativePath.replace(/\/$/, "")), + ]); + for (const relativePath of [...fixturePaths].sort()) { + const source = path.join(repositoryRoot, ...relativePath.split("/")); + const destination = path.join(root, ...relativePath.split("/")); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + if (fs.statSync(source).isDirectory()) { + fs.cpSync(source, destination, { recursive: true }); + } else { + fs.copyFileSync(source, destination); + } + } +} + +function snapshotFiles(root) { + const snapshot = new Map(); + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(absolutePath); + } else { + assert.equal(entry.isFile(), true, `fixture contains a non-file entry: ${absolutePath}`); + const relativePath = path.relative(root, absolutePath).split(path.sep).join("/"); + snapshot.set(relativePath, fs.readFileSync(absolutePath)); + } + } + } + visit(root); + return snapshot; +} + +function runNpmPack(root, outputDirectory, cacheDirectory) { + const npmArguments = [ + "pack", + "--pack-destination", + outputDirectory, + "--json", + "--ignore-scripts", + ]; + const environment = { + ...process.env, + ANCHORLOOP_RELEASE_TAG: `v${SUPPORTED_VERSION}`, + npm_config_audit: "false", + npm_config_cache: cacheDirectory, + npm_config_fund: "false", + npm_config_offline: "true", + npm_config_update_notifier: "false", + }; + const npmExecutable = process.env.npm_execpath; + return npmExecutable + ? spawnSync(process.execPath, [npmExecutable, ...npmArguments], { + cwd: root, + encoding: "utf8", + env: environment, + shell: false, + timeout: 60_000, + windowsHide: true, + }) + : spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", npmArguments, { + cwd: root, + encoding: "utf8", + env: environment, + shell: process.platform === "win32", + timeout: 60_000, + windowsHide: true, + }); +} + +function tarString(buffer, start, length) { + const field = buffer.subarray(start, start + length); + const nul = field.indexOf(0); + return field.subarray(0, nul === -1 ? field.length : nul).toString("utf8"); +} + +function tarOctal(buffer, start, length) { + const value = tarString(buffer, start, length).trim(); + if (value === "") { + return 0; + } + assert.match(value, /^[0-7]+$/, `invalid tar octal field: ${JSON.stringify(value)}`); + return Number.parseInt(value, 8); +} + +function paxAttributes(data) { + const attributes = {}; + let offset = 0; + while (offset < data.length) { + const separator = data.indexOf(0x20, offset); + assert(separator > offset, "invalid PAX record length"); + const recordLength = Number.parseInt(data.subarray(offset, separator).toString("ascii"), 10); + assert(Number.isSafeInteger(recordLength) && recordLength > 0, "invalid PAX record size"); + const recordEnd = offset + recordLength; + assert(recordEnd <= data.length, "truncated PAX record"); + const record = data.subarray(separator + 1, recordEnd - 1).toString("utf8"); + const equals = record.indexOf("="); + assert(equals > 0, "invalid PAX attribute"); + attributes[record.slice(0, equals)] = record.slice(equals + 1); + offset = recordEnd; + } + return attributes; +} + +function readTarGz(archivePath) { + const archive = zlib.gunzipSync(fs.readFileSync(archivePath)); + const entries = new Map(); + let offset = 0; + let pendingLongPath = null; + let pendingPax = null; + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + break; + } + const size = tarOctal(header, 124, 12); + const type = String.fromCharCode(header[156] || 0); + const dataStart = offset + 512; + const dataEnd = dataStart + size; + assert(dataEnd <= archive.length, "truncated tar entry"); + const data = archive.subarray(dataStart, dataEnd); + const prefix = tarString(header, 345, 155); + const headerPath = [prefix, tarString(header, 0, 100)].filter(Boolean).join("/"); + + if (type === "x") { + pendingPax = paxAttributes(data); + } else if (type === "g") { + // Global PAX metadata does not name a package payload entry. + } else if (type === "L") { + pendingLongPath = tarString(data, 0, data.length); + } else { + const entryPath = pendingPax?.path || pendingLongPath || headerPath; + assert(entryPath, "tar payload entry has no path"); + entries.set(entryPath, { + content: type === "0" || type === "\0" ? Buffer.from(data) : null, + type, + }); + pendingLongPath = null; + pendingPax = null; + } + offset = dataStart + Math.ceil(size / 512) * 512; + } + return entries; +} + +function extractRegularFiles(entries, destinationRoot) { + fs.mkdirSync(destinationRoot, { recursive: true }); + const resolvedRoot = path.resolve(destinationRoot); + for (const [entryPath, entry] of entries) { + if (entry.content === null) { + continue; + } + assert.match(entryPath, /^package\//, `unexpected tar root: ${entryPath}`); + const destination = path.resolve(destinationRoot, ...entryPath.split("/")); + assert( + destination.startsWith(`${resolvedRoot}${path.sep}`), + `tar path escapes extraction root: ${entryPath}`, + ); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, entry.content); + } +} + +test("release documentation is finalized deterministically without touching runtime", () => { + const first = fixtureRoot(); + const second = fixtureRoot(); + const sourceReadme = fs.readFileSync(path.join(repositoryRoot, "README.md"), "utf8"); + const runtimeBefore = fs.readFileSync(path.join(first, "src", "anchorloop", "version.py"), "utf8"); + try { + const firstPaths = finalizeReleaseDocs(first, SUPPORTED_VERSION, RELEASE_DATE); + const secondPaths = finalizeReleaseDocs(second, SUPPORTED_VERSION, RELEASE_DATE); + assert.deepEqual(firstPaths, secondPaths); + assert.equal(firstPaths.length, 15); + assert.doesNotThrow(() => assertReleaseArtifactDocs(first, SUPPORTED_VERSION, RELEASE_DATE)); + assert.doesNotThrow(() => assertReleaseArtifactDocs(second, SUPPORTED_VERSION, RELEASE_DATE)); + + const firstDocuments = readDocuments(first); + const secondDocuments = readDocuments(second); + assert.deepEqual(firstDocuments, secondDocuments); + for (const [relativePath, content] of firstDocuments) { + assert.doesNotMatch(content, /unreleased|release[- ]candidate|published production/i, relativePath); + } + assert.match(firstDocuments.get("README.md"), /\*\*Release artifact:\*\* `anchorloop@0\.2\.0`/); + assert.match(firstDocuments.get("README.md"), /\*\*Artifact date:\*\* `2026-07-15`/); + assert.doesNotMatch(firstDocuments.get("README.md"), /raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/main\//); + assert.match(firstDocuments.get("README.md"), /raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/v0\.2\.0\//); + assert.match(firstDocuments.get("CONTRIBUTING.md"), /exact release artifact/); + assert.equal( + fs.readFileSync(path.join(first, "src", "anchorloop", "version.py"), "utf8"), + runtimeBefore, + ); + assert.equal(fs.readFileSync(path.join(repositoryRoot, "README.md"), "utf8"), sourceReadme); + } finally { + fs.rmSync(first, { recursive: true, force: true }); + fs.rmSync(second, { recursive: true, force: true }); + } +}); + +test( + "a clean finalized fixture produces an actual neutral npm tarball", + { timeout: 120_000 }, + () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "anchorloop-release-pack-")); + const fixture = path.join(sandbox, "fixture"); + const packOutput = path.join(sandbox, "pack-output"); + const npmCache = path.join(sandbox, "npm-cache"); + const extracted = path.join(sandbox, "extracted"); + try { + copyPackageFixture(fixture); + const before = snapshotFiles(fixture); + const finalizer = spawnSync( + process.execPath, + [ + path.join(fixture, "npm", "scripts", "finalize-release-docs.js"), + "--root", + fixture, + "--version", + SUPPORTED_VERSION, + "--release-date", + RELEASE_DATE, + ], + { + cwd: fixture, + encoding: "utf8", + shell: false, + timeout: 30_000, + windowsHide: true, + }, + ); + assert.equal(finalizer.status, 0, `${finalizer.stdout}\n${finalizer.stderr}`); + assert.match(finalizer.stdout, /Prepared 15 release artifact documents/); + assert.doesNotThrow(() => + assertReleaseArtifactDocs(fixture, SUPPORTED_VERSION, RELEASE_DATE), + ); + + const after = snapshotFiles(fixture); + assert.deepEqual( + [...after.keys()].sort(), + [...before.keys()].sort(), + "the finalizer must not create untracked fixture files", + ); + const changedPaths = [...after] + .filter(([relativePath, content]) => !content.equals(before.get(relativePath))) + .map(([relativePath]) => relativePath) + .sort(); + const expectedChanges = releaseDocumentSpecs(SUPPORTED_VERSION, RELEASE_DATE) + .map((spec) => spec.path) + .sort(); + assert.deepEqual( + changedPaths, + expectedChanges, + "the finalizer may mutate only the declared documentation set", + ); + + fs.mkdirSync(packOutput); + const pack = runNpmPack(fixture, packOutput, npmCache); + assert.equal(pack.status, 0, `${pack.error || ""}\n${pack.stdout}\n${pack.stderr}`); + const archives = fs.readdirSync(packOutput).filter((name) => name.endsWith(".tgz")); + assert.equal(archives.length, 1, `expected one npm tarball, found ${archives.join(", ")}`); + + const entries = readTarGz(path.join(packOutput, archives[0])); + const entryPaths = [...entries.keys()].sort(); + const forbiddenPayload = [ + /^package\/npm\/scripts\/finalize-release-docs\.js$/, + /^package\/npm\/test\//, + /^package\/(?:tests?|fixtures?)\//, + /^package\/(?:node_modules|\.anchor|\.codex|\.git)\//, + /(?:^|\/)__pycache__\//, + /\.pyc$/, + /(?:^|\/)[^/]+\.egg-info\//, + /^package\/(?:dist-npm|npm-cache|\.npm-cache)\//, + /^package\/package-lock\.json$/, + ]; + for (const entryPath of entryPaths) { + assert.equal( + forbiddenPayload.some((pattern) => pattern.test(entryPath)), + false, + `forbidden finalized tarball entry: ${entryPath}`, + ); + } + for (const requiredPath of [ + "package/package.json", + "package/README.md", + "package/CONTRIBUTING.md", + "package/src/anchorloop/version.py", + ...expectedChanges.map((relativePath) => `package/${relativePath}`), + ]) { + assert(entries.has(requiredPath), `missing finalized tarball entry: ${requiredPath}`); + } + + extractRegularFiles(entries, extracted); + const packagedRoot = path.join(extracted, "package"); + for (const spec of releaseDocumentSpecs(SUPPORTED_VERSION, RELEASE_DATE)) { + const fixtureDocument = fs.readFileSync( + path.join(fixture, ...spec.path.split("/")), + "utf8", + ); + const packagedDocument = fs.readFileSync( + path.join(packagedRoot, ...spec.path.split("/")), + "utf8", + ); + assert.equal(packagedDocument, fixtureDocument, `${spec.path} changed during npm pack`); + assert.doesNotMatch( + packagedDocument, + /unreleased|release[- ]candidate|published production/i, + `${spec.path} retained source-only release status`, + ); + } + + const packagedReadme = fs.readFileSync(path.join(packagedRoot, "README.md"), "utf8"); + assert.match(packagedReadme, /\*\*Release artifact:\*\* `anchorloop@0\.2\.0`/); + assert.match(packagedReadme, /\*\*Artifact date:\*\* `2026-07-15`/); + assert.doesNotMatch(packagedReadme, /raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/main\//); + assert.match(packagedReadme, /raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/v0\.2\.0\/docs\/assets\/anchorloop-delivery-loop\.svg/); + assert.match(packagedReadme, /raw\.githubusercontent\.com\/ppmarkek\/AnchorLoop\/v0\.2\.0\/docs\/assets\/anchorloop-evidence-integrity\.svg/); + + const packagedContributing = fs.readFileSync( + path.join(packagedRoot, "CONTRIBUTING.md"), + "utf8", + ); + assert.match(packagedContributing, /stages the finalized Git\s+checkout directory/); + assert.match(packagedContributing, /post-promotion documentation PR against `main`/); + assert.doesNotMatch(packagedContributing, /publishes it with npm provenance through OIDC/i); + assert.doesNotMatch(packagedContributing, /exact-source/i); + } finally { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }, +); + +test("release documentation finalization fails closed and does not partially write", () => { + const root = fixtureRoot(); + try { + const readmePath = path.join(root, "README.md"); + fs.writeFileSync( + readmePath, + fs.readFileSync(readmePath, "utf8").replace( + "**Published production:** `anchorloop@0.1.0`", + "**Production marker changed unexpectedly:** `anchorloop@0.1.0`", + ), + "utf8", + ); + const before = readDocuments(root); + assert.throws( + () => finalizeReleaseDocs(root, SUPPORTED_VERSION, RELEASE_DATE), + /README\.md: expected the release-candidate source fragment exactly once, found 0/, + ); + assert.deepEqual(readDocuments(root), before); + assert.throws( + () => assertReleaseArtifactDocs(root, SUPPORTED_VERSION, RELEASE_DATE), + /README\.md: release artifact marker is missing/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("a new release version requires an explicit documentation transformation update", () => { + const root = fixtureRoot(); + try { + assert.throws( + () => finalizeReleaseDocs(root, "0.3.0", RELEASE_DATE), + /update its exact transformations before releasing 0\.3\.0/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("release artifact date must be supplied explicitly and be calendar-valid", () => { + const root = fixtureRoot(); + try { + assert.throws( + () => finalizeReleaseDocs(root, SUPPORTED_VERSION, null), + /must use YYYY-MM-DD from the tagged commit/, + ); + assert.throws( + () => finalizeReleaseDocs(root, SUPPORTED_VERSION, "2026-02-30"), + /date is invalid/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/package.json b/package.json index 41df5ab..9dbb4de 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "scripts": { "test": "npm run test:npm", - "test:npm": "node --test npm/test/anchorloop.test.js npm/test/version.test.js", + "test:npm": "node --test npm/test/anchorloop.test.js npm/test/release-docs.test.js npm/test/version.test.js", "version:check": "node npm/scripts/version.js", "prepack": "npm run version:check", "pack:check": "npm pack --dry-run", diff --git a/src/anchorloop/cli.py b/src/anchorloop/cli.py index d127961..7480964 100644 --- a/src/anchorloop/cli.py +++ b/src/anchorloop/cli.py @@ -979,7 +979,10 @@ def _print_experiment_report( "agent_provider", "agent_model", "delayed_recall_score", + "delayed_recall_integrity", "outcome_observations", + "latest_outcome_integrity", + "legacy_unverified_observations", "defects_found", "rollback", "corrective_refactor", diff --git a/src/anchorloop/project.py b/src/anchorloop/project.py index 4086fac..0ac5199 100644 --- a/src/anchorloop/project.py +++ b/src/anchorloop/project.py @@ -18,6 +18,7 @@ GitInspectionError, git_changed_path_names, run_precommit, + task_change_baseline, workspace_fingerprint, ) from .project_lock import ProjectLock @@ -42,6 +43,7 @@ VERIFICATION_RESULTS = {"pass", "fail", "partial", "not-applicable"} CAREFUL_RECALL_DELAY_HOURS = 24 TASK_SCHEMA_VERSION = 2 +POST_CLOSE_INTEGRITY_FORMAT_VERSION = 1 # These are deliberately conservative signals. They promote a task only when # an engineer explicitly names a sensitive area or provides a recognisably @@ -862,6 +864,7 @@ def approve_task( if "plan" not in task: raise AnchorError("A recorded plan is required before approval.") self._normalize_task_schema_for_approval(task) + self._ensure_task_change_baseline(task) snapshot = self._active_rules_snapshot() task["ruleset"] = snapshot approval_subject = self._task_approval_subject(task) @@ -888,9 +891,10 @@ def task_approval_preview(self) -> dict[str, Any]: if "plan" not in task: raise AnchorError("A recorded plan is required before approval.") self._normalize_task_schema_for_approval(task) + self._ensure_task_change_baseline(task) task["ruleset"] = self._active_rules_snapshot() subject = self._task_approval_subject(task) - return { + preview = { "task_id": task["id"], "title": task["title"], "mode": task.get("mode", "FAST"), @@ -898,6 +902,11 @@ def task_approval_preview(self) -> dict[str, Any]: "subject_digest": self._document_digest(subject), "ruleset_version": task["ruleset"]["version"], } + if "actual_diff_revision_evidence" in subject: + preview["actual_diff_revision_evidence"] = subject[ + "actual_diff_revision_evidence" + ] + return preview @mutating("task.verify") def verify_task( @@ -1044,7 +1053,7 @@ def record_delayed_recall( now = datetime.now(UTC) if now < due_at: raise AnchorError(f"Delayed recall is not due until {due_at.isoformat()}.") - comprehension["delayed_recall"] = { + recall_record = { "statement": self._required_text(response, "Delayed recall response"), "score": score, "by": self._required_text(by, "Engineer name"), @@ -1052,6 +1061,12 @@ def record_delayed_recall( "recorded_at": now.isoformat(), "delay_seconds": max(0.0, (now - due_at).total_seconds()), } + self._seal_post_close_record( + task, + recall_record, + record_type="delayed_recall", + ) + comprehension["delayed_recall"] = recall_record task.setdefault("metrics", {})["delayed_recall_score"] = score self._append_task_event(task, "task.delayed-recall.recorded") self._write_json(path, task) @@ -1092,6 +1107,11 @@ def record_post_completion_outcome( "notes": self._required_text(notes, "Post-completion notes"), "provenance": "reported-post-completion", } + self._seal_post_close_record( + task, + record, + record_type="post_completion_outcome", + ) task.setdefault("post_completion_outcomes", []).append(record) task.setdefault("metrics", {})["latest_post_completion_outcome"] = { key: record[key] @@ -1123,6 +1143,21 @@ def experiment_report(self) -> dict[str, Any]: if not isinstance(outcomes, list): outcomes = [] latest_outcome = outcomes[-1] if outcomes and isinstance(outcomes[-1], dict) else {} + delayed_integrity = self._post_close_record_integrity(task, delayed) + outcome_integrities = [ + self._post_close_record_integrity(task, record) + for record in outcomes + if isinstance(record, dict) + ] + latest_outcome_integrity = ( + outcome_integrities[-1] if outcome_integrities else None + ) + legacy_unverified_observations = int( + delayed_integrity == "legacy-unverified" + ) + sum( + integrity == "legacy-unverified" + for integrity in outcome_integrities + ) input_tokens = metrics.get("input_tokens") output_tokens = metrics.get("output_tokens") total_tokens = ( @@ -1146,7 +1181,10 @@ def experiment_report(self) -> dict[str, Any]: "agent_provider": metrics.get("agent_provider"), "agent_model": metrics.get("agent_model"), "delayed_recall_score": delayed.get("score"), + "delayed_recall_integrity": delayed_integrity, "outcome_observations": len(outcomes), + "latest_outcome_integrity": latest_outcome_integrity, + "legacy_unverified_observations": legacy_unverified_observations, "defects_found": latest_outcome.get("defects_found"), "rollback": latest_outcome.get("rollback"), "corrective_refactor": latest_outcome.get("corrective_refactor"), @@ -1173,6 +1211,9 @@ def experiment_report(self) -> dict[str, Any]: "tasks_with_outcome_observation": sum( int(row["outcome_observations"] > 0) for row in rows ), + "legacy_unverified_observations": sum( + row["legacy_unverified_observations"] for row in rows + ), "average_delayed_recall_score": ( sum(recall_scores) / len(recall_scores) if recall_scores else None ), @@ -1200,10 +1241,18 @@ def revise_task(self, *, target: str, reason: str) -> dict[str, Any]: "quality": task.get("quality", []), } if target == "plan": + if normalized_reason == _ACTUAL_DIFF_REVISION_REASON: + revision["actual_diff_risk_paths"] = self._actual_diff_risk_paths(task) revision["approval"] = task.get("approval") revision["ruleset"] = task.get("ruleset") + revision["change_baseline"] = task.get("change_baseline") task.setdefault("revisions", []).append(revision) - for field in ("approval", "ruleset", "quality", "verification"): + for field in ( + "approval", + "ruleset", + "quality", + "verification", + ): task.pop(field, None) task["state"] = "planned" self._append_task_event(task, "task.revise.plan") @@ -1265,7 +1314,15 @@ def transition(self, action: str) -> dict[str, Any]: closed_at = self._closed_at(task) delay = self._recall_delay_hours(comprehension) comprehension["recall_due_at"] = (closed_at + timedelta(hours=delay)).isoformat() - if action == "close": + if action == "close" and task.get("schema_version") == TASK_SCHEMA_VERSION: + task["post_close_integrity"] = { + "format_version": POST_CLOSE_INTEGRITY_FORMAT_VERSION, + "head_sequence": 0, + "head_digest": None, + } + task["close_digest"] = self._closed_task_digest(task) + task["post_close_integrity"]["head_digest"] = task["close_digest"] + elif action == "close": task["close_digest"] = self._closed_task_digest(task) self._append_task_event(task, f"task.{action}") if action == "close": @@ -1310,7 +1367,16 @@ def precommit(self) -> dict[str, Any]: self._ensure_approval_matches_task(task) self._ensure_actual_diff_mode(task) ruleset = task.get("ruleset") or {"rules": {}} - quality = run_precommit(self.root, active_categories=set(ruleset["rules"])) + try: + quality = run_precommit( + self.root, + active_categories=set(ruleset["rules"]), + baseline=task.get("change_baseline"), + ) + except GitInspectionError as error: + raise AnchorError( + f"Cannot inspect pre-commit source snapshots safely: {error}" + ) from error task.setdefault("quality", []).append(quality) self._write_json(self.active_task_path, task) if quality["status"] == "blocked": @@ -1868,9 +1934,9 @@ def _recommended_mode( def _ensure_actual_diff_mode(self, task: dict[str, Any]) -> None: """Block a low-ownership task when its material diff is CAREFUL.""" + risk_paths = self._actual_diff_risk_paths(task) if task.get("mode") == "CAREFUL": return - risk_paths = self._actual_diff_risk_paths() if not risk_paths: return @@ -1887,7 +1953,7 @@ def _ensure_actual_diff_mode(self, task: dict[str, Any]) -> None: ) if ( self._actual_diff_override_covers(override, risk_paths) - and self._has_actual_diff_revision(task) + and self._has_actual_diff_revision(task, risk_paths) ): return @@ -1904,14 +1970,24 @@ def _ensure_actual_diff_mode(self, task: dict[str, Any]) -> None: "every detected path." ) - def _actual_diff_risk_paths(self) -> list[str]: + def _actual_diff_risk_paths(self, task: dict[str, Any]) -> list[str]: risky: list[str] = [] + baseline = task.get("change_baseline") + if not isinstance(baseline, dict): + raise AnchorError( + "Cannot evaluate the actual diff safely; the approved task has no " + "change baseline. Revise and approve the plan again." + ) try: - changed_paths = git_changed_path_names(self.root, strict=True) + changed_paths = git_changed_path_names( + self.root, + baseline=baseline, + strict=True, + ) except GitInspectionError as error: raise AnchorError( - "Cannot evaluate the actual Git diff safely; review and precommit are blocked. " - "Restore Git access and retry." + "Cannot evaluate the actual diff safely; review and precommit are blocked. " + f"{error} Restore the approved baseline and retry." ) from error for path in changed_paths: path_text = "/" + path.casefold().strip("/") @@ -1923,20 +1999,127 @@ def _actual_diff_risk_paths(self) -> list[str]: risky.append(path) return risky - @staticmethod - def _has_actual_diff_revision(task: dict[str, Any]) -> bool: - revisions = task.get("revisions") - if not isinstance(revisions, list): + def _ensure_task_change_baseline(self, task: dict[str, Any]) -> None: + """Capture one immutable task baseline before its first approval.""" + + existing = task.get("change_baseline") + if isinstance(existing, dict): + self._materialize_missing_submodule_baselines(existing) + return + try: + task["change_baseline"] = task_change_baseline(self.root) + except GitInspectionError as error: + raise AnchorError( + "Cannot capture the task change baseline safely; approval is blocked. " + f"{error}" + ) from error + + def _materialize_missing_submodule_baselines( + self, + baseline: dict[str, Any], + ) -> None: + """Fill only newly materialized submodules without moving task origin. + + Plan revision must not advance the outer baseline to current HEAD or a + committed sensitive diff could disappear. An approved uninitialized + submodule is the narrow exception: once materialized at its tracked + gitlink, capture its nested baseline while preserving the original + outer commit. + """ + + if baseline.get("source") != "git": + return + submodules = baseline.get("submodules") + missing = ( + [name for name, nested in submodules.items() if nested is None] + if isinstance(submodules, dict) + else [] + ) + if not missing: + return + try: + current = task_change_baseline(self.root) + except GitInspectionError as error: + raise AnchorError( + "Cannot inspect newly materialized Git submodules safely; approval is blocked. " + f"{error}" + ) from error + if current.get("source") != "git": + raise AnchorError("The preserved Git task baseline no longer belongs to a Git project.") + expected_oids = baseline.get("submodule_oids") + current_oids = current.get("submodule_oids") + current_submodules = current.get("submodules") + if ( + not isinstance(expected_oids, dict) + or not isinstance(current_oids, dict) + or not isinstance(current_submodules, dict) + or set(expected_oids) != set(current_oids) + ): + raise AnchorError( + "The Git submodule set changed after the task baseline was captured." + ) + for name in missing: + nested = current_submodules.get(name) + if nested is None: + continue + if ( + not isinstance(nested, dict) + or nested.get("source") != "git" + or nested.get("head") != current_oids.get(name) + or current_oids.get(name) != expected_oids.get(name) + ): + raise AnchorError( + f"Materialized Git submodule does not match its approved gitlink: {name}" + ) + submodules[name] = nested + + @classmethod + def _has_actual_diff_revision( + cls, + task: dict[str, Any], + risk_paths: list[str], + ) -> bool: + required = set(risk_paths) + if not required: return False return any( - isinstance(revision, dict) - and revision.get("target") == "plan" - and revision.get("reason") == _ACTUAL_DIFF_REVISION_REASON - and revision.get("previous_state") - in {"implementing", "review_ready", "quality_checked"} - for revision in revisions + required.issubset(set(evidence["risk_paths"])) + for evidence in cls._actual_diff_revision_evidence(task) ) + @staticmethod + def _actual_diff_revision_evidence( + task: dict[str, Any], + ) -> list[dict[str, Any]]: + revisions = task.get("revisions") + if not isinstance(revisions, list): + return [] + evidence: list[dict[str, Any]] = [] + for revision in revisions: + if ( + not isinstance(revision, dict) + or revision.get("target") != "plan" + or revision.get("reason") != _ACTUAL_DIFF_REVISION_REASON + or revision.get("previous_state") + not in {"implementing", "review_ready", "quality_checked"} + ): + continue + paths = revision.get("actual_diff_risk_paths") + if ( + not isinstance(paths, list) + or not paths + or not all(isinstance(path, str) and path for path in paths) + ): + continue + evidence.append( + { + "at": revision.get("at"), + "previous_state": revision.get("previous_state"), + "risk_paths": sorted(set(paths)), + } + ) + return evidence + @staticmethod def _actual_diff_override_covers( override: object, @@ -2372,6 +2555,11 @@ def validate_task_schema(cls, task: object) -> None: ): raise AnchorError("Only CAREFUL tasks may carry a delayed-recall policy or record.") + change_baseline = task.get("change_baseline") + if change_baseline is not None: + cls._validate_task_change_baseline(change_baseline) + + protected_post_close = task.get("post_close_integrity") is not None delayed_recall = comprehension.get("delayed_recall") if delayed_recall is not None: if not isinstance(delayed_recall, dict): @@ -2389,6 +2577,68 @@ def validate_task_schema(cls, task: object) -> None: or delay_seconds < 0 ): raise AnchorError("Delayed recall delay must be a finite non-negative number.") + recall_digest = delayed_recall.get("record_digest") + if recall_digest is None: + if protected_post_close: + raise AnchorError( + "Protected delayed recall records require a record digest." + ) + else: + cls._required_text(recall_digest, "Delayed recall record digest") + if recall_digest != cls._record_digest(delayed_recall): + raise AnchorError( + "delayed recall record digest does not match its contents." + ) + + outcomes = task.get("post_completion_outcomes") + if outcomes is not None: + if state != "closed": + raise AnchorError("Only closed tasks may carry post-completion outcomes.") + if not isinstance(outcomes, list): + raise AnchorError("Post-completion outcomes must be a list.") + for record in outcomes: + if not isinstance(record, dict): + raise AnchorError("Post-completion outcome record must be an object.") + for field in ("at", "by", "notes", "provenance"): + cls._required_text( + record.get(field), + f"Post-completion outcome {field}", + ) + defects_found = record.get("defects_found") + if ( + not isinstance(defects_found, int) + or isinstance(defects_found, bool) + or defects_found < 0 + ): + raise AnchorError( + "Post-completion outcome defects must be a non-negative integer." + ) + if not isinstance(record.get("rollback"), bool) or not isinstance( + record.get("corrective_refactor"), bool + ): + raise AnchorError( + "Post-completion outcome rollback and corrective-refactor values must be boolean." + ) + if record.get("provenance") != "reported-post-completion": + raise AnchorError("Post-completion outcome provenance is invalid.") + outcome_digest = record.get("record_digest") + if outcome_digest is None: + if protected_post_close: + raise AnchorError( + "Protected post-completion outcome records require a record digest." + ) + else: + cls._required_text( + outcome_digest, + "Post-completion outcome record digest", + ) + if outcome_digest != cls._record_digest(record): + raise AnchorError( + "post-completion outcome record digest does not match its contents." + ) + + if protected_post_close and state != "closed": + raise AnchorError("Only closed tasks may carry post-close integrity evidence.") approved_states = { "approved", @@ -2568,6 +2818,7 @@ def validate_task_schema(cls, task: object) -> None: cls._required_text(close_digest, "Anchor task close digest") if close_digest != cls._closed_task_digest(task): raise AnchorError("Anchor task close digest does not match immutable close evidence.") + cls._validate_post_close_integrity(task) @staticmethod def _created_at(task: dict[str, Any]) -> datetime: @@ -2768,10 +3019,226 @@ def _record_digest(cls, record: dict[str, Any]) -> str: {key: value for key, value in record.items() if key != "record_digest"} ) + @classmethod + def _seal_post_close_record( + cls, + task: dict[str, Any], + record: dict[str, Any], + *, + record_type: str, + ) -> None: + integrity = task.get("post_close_integrity") + if not isinstance(integrity, dict): + record["record_digest"] = cls._record_digest(record) + return + head_sequence = integrity.get("head_sequence") + head_digest = integrity.get("head_digest") + if ( + not isinstance(head_sequence, int) + or isinstance(head_sequence, bool) + or head_sequence < 0 + or not isinstance(head_digest, str) + ): + raise AnchorError("Post-close integrity head is invalid.") + record.update( + { + "record_type": record_type, + "task_id": task.get("id"), + "sequence": head_sequence + 1, + "previous_digest": head_digest, + } + ) + record["record_digest"] = cls._record_digest(record) + integrity["head_sequence"] = record["sequence"] + integrity["head_digest"] = record["record_digest"] + + @classmethod + def _validate_post_close_integrity(cls, task: dict[str, Any]) -> None: + integrity = task.get("post_close_integrity") + if integrity is None: + return + if not isinstance(integrity, dict): + raise AnchorError("Post-close integrity discriminator must be an object.") + if integrity.get("format_version") != POST_CLOSE_INTEGRITY_FORMAT_VERSION: + raise AnchorError("Post-close integrity format version is invalid.") + head_sequence = integrity.get("head_sequence") + head_digest = integrity.get("head_digest") + if ( + not isinstance(head_sequence, int) + or isinstance(head_sequence, bool) + or head_sequence < 0 + ): + raise AnchorError("Post-close integrity head sequence is invalid.") + if ( + not isinstance(head_digest, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", head_digest) is None + ): + raise AnchorError("Post-close integrity head digest is invalid.") + close_digest = task.get("close_digest") + if not isinstance(close_digest, str): + raise AnchorError("Post-close integrity requires a close digest anchor.") + + records: list[tuple[dict[str, Any], str]] = [] + comprehension = task.get("comprehension") + delayed_recall = ( + comprehension.get("delayed_recall") + if isinstance(comprehension, dict) + else None + ) + if isinstance(delayed_recall, dict): + records.append((delayed_recall, "delayed_recall")) + outcomes = task.get("post_completion_outcomes") + outcome_records = outcomes if isinstance(outcomes, list) else [] + outcome_sequences = [ + record.get("sequence") + for record in outcome_records + if isinstance(record, dict) + ] + try: + outcomes_are_ordered = outcome_sequences == sorted(outcome_sequences) + except TypeError as error: + raise AnchorError("Post-completion outcome record sequence is invalid.") from error + if not outcomes_are_ordered: + raise AnchorError("Post-completion outcome record order is invalid.") + records.extend( + (record, "post_completion_outcome") + for record in outcome_records + if isinstance(record, dict) + ) + try: + ordered = sorted(records, key=lambda item: item[0].get("sequence")) + except TypeError as error: + raise AnchorError("Post-close record sequence is invalid.") from error + if len(ordered) != head_sequence: + raise AnchorError("Post-close record chain length does not match its head.") + + previous_digest = close_digest + seen_digests: set[str] = set() + for expected_sequence, (record, expected_type) in enumerate(ordered, start=1): + if record.get("record_type") != expected_type: + raise AnchorError("Post-close record type does not match its storage location.") + if record.get("task_id") != task.get("id"): + raise AnchorError("Post-close record belongs to a different task.") + if record.get("sequence") != expected_sequence: + raise AnchorError("Post-close record sequence is not contiguous.") + if record.get("previous_digest") != previous_digest: + raise AnchorError("Post-close record previous digest does not match the chain.") + record_digest = record.get("record_digest") + if ( + not isinstance(record_digest, str) + or record_digest != cls._record_digest(record) + ): + raise AnchorError("Post-close record digest does not match its contents.") + if record_digest in seen_digests: + raise AnchorError("Post-close record digest is duplicated.") + seen_digests.add(record_digest) + previous_digest = record_digest + if head_digest != previous_digest: + raise AnchorError("Post-close integrity head does not match the record chain.") + + @staticmethod + def _post_close_record_integrity( + task: dict[str, Any], + record: dict[str, Any], + ) -> str | None: + if not record: + return None + if not isinstance(task.get("post_close_integrity"), dict): + return "legacy-unverified" + return ( + "chain-verified" + if record.get("record_digest") is not None + else "legacy-unverified" + ) + + @classmethod + def _validate_task_change_baseline(cls, baseline: object) -> None: + if not isinstance(baseline, dict): + raise AnchorError("Anchor task change baseline must be an object.") + if baseline.get("format_version") != 1: + raise AnchorError("Anchor task change baseline has an invalid format version.") + source = baseline.get("source") + if source == "git": + head = cls._required_text( + baseline.get("head"), + "Anchor task Git baseline commit", + ) + if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", head) is None: + raise AnchorError("Anchor task Git baseline commit is invalid.") + if not isinstance(baseline.get("project_prefix"), str): + raise AnchorError("Anchor task Git baseline project path is invalid.") + submodules = baseline.get("submodules", {}) + if not isinstance(submodules, dict): + raise AnchorError("Anchor task Git submodule baselines are invalid.") + submodule_oids = baseline.get("submodule_oids", {}) + if ( + not isinstance(submodule_oids, dict) + or set(submodule_oids) != set(submodules) + or not all( + isinstance(oid, str) + and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", oid) + is not None + for oid in submodule_oids.values() + ) + ): + raise AnchorError("Anchor task Git submodule OIDs are invalid.") + for path, nested in submodules.items(): + parsed_path = Path(path) if isinstance(path, str) else None + if ( + not isinstance(path, str) + or not path + or parsed_path is None + or parsed_path.is_absolute() + or bool(parsed_path.drive) + or bool(parsed_path.root) + or ".." in parsed_path.parts + ): + raise AnchorError("Anchor task Git submodule path is invalid.") + if nested is not None: + cls._validate_task_change_baseline(nested) + if nested.get("source") != "git": + raise AnchorError( + "Materialized Git submodules require a Git task baseline." + ) + return + if source == "filesystem": + entries = baseline.get("entries") + if not isinstance(entries, dict): + raise AnchorError("Anchor task filesystem baseline entries are invalid.") + git_context = baseline.get("git_context") + if git_context is not None and git_context not in {"none", "unborn"}: + raise AnchorError("Anchor task filesystem Git context is invalid.") + git_unborn = baseline.get("git_unborn") + if git_unborn is not None and git_unborn is not True: + raise AnchorError("Anchor task unborn Git baseline marker is invalid.") + if (git_unborn is True or git_context == "unborn") and not isinstance( + baseline.get("project_prefix"), + str, + ): + raise AnchorError("Anchor task unborn Git project path is invalid.") + for path, digest in entries.items(): + parsed_path = Path(path) if isinstance(path, str) else None + if ( + not isinstance(path, str) + or not path + or parsed_path is None + or parsed_path.is_absolute() + or bool(parsed_path.drive) + or bool(parsed_path.root) + or ".." in parsed_path.parts + or not isinstance(digest, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None + ): + raise AnchorError( + "Anchor task filesystem baseline entries are invalid." + ) + return + raise AnchorError("Anchor task change baseline source is invalid.") + @staticmethod def _verification_metric_snapshot(task: dict[str, Any]) -> dict[str, Any]: metrics = task.get("metrics") if isinstance(task.get("metrics"), dict) else {} - return { + subject = { field: metrics.get(field) for field in ( "agent_turns", @@ -2783,6 +3250,7 @@ def _verification_metric_snapshot(task: dict[str, Any]) -> dict[str, Any]: "provenance", ) } + return subject @classmethod def _closed_task_subject(cls, task: dict[str, Any]) -> dict[str, Any]: @@ -2796,7 +3264,7 @@ def _closed_task_subject(cls, task: dict[str, Any]) -> dict[str, Any]: ) } if isinstance(comprehension, dict) else None metrics = task.get("metrics") if isinstance(task.get("metrics"), dict) else {} - return { + subject = { "schema_version": task.get("schema_version"), "task_id": task.get("id"), "state": task.get("state"), @@ -2812,6 +3280,10 @@ def _closed_task_subject(cls, task: dict[str, Any]) -> dict[str, Any]: "wall_seconds": metrics.get("wall_seconds"), }, } + integrity = task.get("post_close_integrity") + if isinstance(integrity, dict): + subject["post_close_integrity_format"] = integrity.get("format_version") + return subject @classmethod def _closed_task_digest(cls, task: dict[str, Any]) -> str: @@ -2825,8 +3297,8 @@ def _normalize_task_schema_for_approval(task: dict[str, Any]) -> None: if task.get("schema_version") is None: task["schema_version"] = TASK_SCHEMA_VERSION - @staticmethod - def _task_approval_subject(task: dict[str, Any]) -> dict[str, Any]: + @classmethod + def _task_approval_subject(cls, task: dict[str, Any]) -> dict[str, Any]: comprehension = task.get("comprehension") # New records bind the immutable delay policy, not a timestamp that is # intentionally assigned only when the task closes. Preserve the old @@ -2856,8 +3328,13 @@ def _task_approval_subject(task: dict[str, Any]) -> dict[str, Any]: "comprehension_policy": comprehension_policy, "ruleset": task.get("ruleset"), } + if "change_baseline" in task: + subject["change_baseline"] = task.get("change_baseline") if "schema_version" in task: subject["schema_version"] = task.get("schema_version") + actual_diff_evidence = cls._actual_diff_revision_evidence(task) + if actual_diff_evidence: + subject["actual_diff_revision_evidence"] = actual_diff_evidence return subject def _task_approval_digests(self, subject: dict[str, Any]) -> dict[str, str]: @@ -2953,9 +3430,15 @@ def _ensure_approval_matches_task(self, task: dict[str, Any]) -> None: "changed_artifacts": changed, "approval": approval, "ruleset": task.get("ruleset"), + "change_baseline": task.get("change_baseline"), } task.setdefault("approval_invalidations", []).append(invalidation) - for field in ("approval", "ruleset", "quality", "verification"): + for field in ( + "approval", + "ruleset", + "quality", + "verification", + ): task.pop(field, None) if state == "briefing": invalidation["plan"] = task.pop("plan", None) @@ -3016,8 +3499,24 @@ def _ensure_quality_matches_workspace(self, task: dict[str, Any]) -> None: "The task returned to review_ready; rerun: " f"{display_command('precommit')}" ) - current = workspace_fingerprint(self.root) - if current.get("digest") == recorded.get("digest"): + try: + current = workspace_fingerprint(self.root) + except GitInspectionError as error: + self._invalidate_quality( + task, + recorded_fingerprint=recorded, + current_fingerprint=None, + reason=f"The workspace can no longer be fingerprinted safely: {error}", + ) + raise StateRecordedError( + "The workspace can no longer be fingerprinted safely. " + "The task returned to review_ready." + ) from error + if ( + current.get("digest") == recorded.get("digest") + and current.get("git_state_digest") + == recorded.get("git_state_digest") + ): return self._invalidate_quality( task, diff --git a/src/anchorloop/quality.py b/src/anchorloop/quality.py index 221205c..91d4808 100644 --- a/src/anchorloop/quality.py +++ b/src/anchorloop/quality.py @@ -15,21 +15,29 @@ _SECRET_ASSIGNMENT = re.compile( r"(?i)(?:api[_-]?key|secret|password|token)\s*[:=]\s*[\"'][^\"']{8,}[\"']" ) +_TASK_BASELINE_MAX_ENTRIES = 100_000 +_TASK_BASELINE_MAX_BYTES = 512 * 1024 * 1024 +_WINDOWS_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) class GitInspectionError(RuntimeError): - """A required Git working-tree query could not be completed.""" + """A required task-baseline query could not be completed safely.""" -def run_precommit(root: Path, *, active_categories: set[str]) -> dict[str, Any]: +def run_precommit( + root: Path, + *, + active_categories: set[str], + baseline: dict[str, Any] | None = None, +) -> dict[str, Any]: """Run small, local checks that are safe before project-specific tools exist.""" findings: list[dict[str, str]] = [] checks: list[dict[str, str]] = [] starting_fingerprint = workspace_fingerprint(root) - files = list(_source_files(root)) + documents = _source_documents(root, baseline=baseline) - syntax_failures = _python_syntax_failures(root, files) + syntax_failures = _python_syntax_failures(documents) if syntax_failures: findings.extend(syntax_failures) checks.append({"name": "python-syntax", "status": "failed"}) @@ -45,16 +53,55 @@ def run_precommit(root: Path, *, active_categories: set[str]) -> dict[str, Any]: } ) else: - secret_findings = _secret_findings(root, files) + secret_findings = _secret_findings(documents) if secret_findings: findings.extend(secret_findings) checks.append({"name": "secret-baseline", "status": "failed"}) else: checks.append({"name": "secret-baseline", "status": "passed"}) - whitespace_checks = ( - _git_whitespace_check(root, ("diff", "--check"), "git-diff-check"), - _git_whitespace_check(root, ("diff", "--cached", "--check"), "git-cached-diff-check"), + whitespace_arguments: list[tuple[tuple[str, ...], str]] = [] + if isinstance(baseline, dict) and baseline.get("source") == "git": + head = baseline.get("head") + if not isinstance(head, str): + raise GitInspectionError("The approved Git baseline commit is invalid.") + whitespace_arguments.append( + (("diff", "--check", head, "HEAD", "--"), "git-baseline-diff-check") + ) + elif isinstance(baseline, dict) and baseline.get("source") == "filesystem": + git_present, head_exists = _filesystem_git_state(root, baseline) + if git_present and head_exists: + empty_tree = _git_bytes( + root, + "hash-object", + "-t", + "tree", + "--stdin", + input_data=b"", + ) + if empty_tree is None: + raise GitInspectionError("Required unborn Git whitespace baseline failed.") + whitespace_arguments.append( + ( + ( + "diff", + "--check", + empty_tree.decode("ascii").strip(), + "HEAD", + "--", + ), + "git-unborn-head-diff-check", + ) + ) + whitespace_arguments.extend( + [ + (("diff", "--check"), "git-diff-check"), + (("diff", "--cached", "--check"), "git-cached-diff-check"), + ] + ) + whitespace_checks = tuple( + _git_whitespace_check(root, arguments, name) + for arguments, name in whitespace_arguments ) checks.extend(whitespace_checks) for check in whitespace_checks: @@ -68,7 +115,11 @@ def run_precommit(root: Path, *, active_categories: set[str]) -> dict[str, Any]: ) ending_fingerprint = workspace_fingerprint(root) - if starting_fingerprint["digest"] != ending_fingerprint["digest"]: + if ( + starting_fingerprint["digest"] != ending_fingerprint["digest"] + or starting_fingerprint.get("git_state_digest") + != ending_fingerprint.get("git_state_digest") + ): findings.append( { "category": "workspace", @@ -95,11 +146,10 @@ def run_precommit(root: Path, *, active_categories: set[str]) -> dict[str, Any]: def workspace_fingerprint(root: Path) -> dict[str, Any]: """Return a deterministic snapshot of the checked workspace state. - Verification is bound to the materialized files, not Git metadata. In a - Git repository tracked and untracked (non-ignored) paths are enumerated by - Git, then read from the working tree. HEAD/index/diff state is recorded in - a separate diagnostic digest so a metadata-only commit does not invalidate - otherwise identical quality evidence. + Verification is bound to both materialized files and the authoritative Git + snapshot inspected by the gate. Tracked and untracked (non-ignored) paths + are enumerated by Git, then read from the working tree; HEAD/index/diff + state is recorded separately so post-gate Git transitions are detectable. """ return _workspace_fingerprint(root.resolve(), ancestors=frozenset()) @@ -168,12 +218,16 @@ def _materialized_fingerprint( # An uninitialized or unsafe submodule has no materialized tree to # read, so bind the evidence to the tracked gitlink instead. _update_digest_value(digest, b"index-oid", submodule["index_oid"]) + aggregate_git_state = _aggregate_git_state_digest( + git_state_digest, + submodules, + ) return { "algorithm": "sha256", "format_version": 3, "digest": f"sha256:{digest.hexdigest()}", "content_digest": f"sha256:{digest.hexdigest()}", - "git_state_digest": git_state_digest, + "git_state_digest": aggregate_git_state, "source": source, "files": len(files) + sum(max(1, int(submodule.get("files", 0))) for submodule in submodules), @@ -186,7 +240,7 @@ def _git_materialized_paths( *, ancestors: frozenset[str], ) -> tuple[list[Path], list[dict[str, Any]]] | None: - if not (root / ".git").exists(): + if _validated_git_context(root, strict=False) is None: return None listed = _git_bytes(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--") @@ -223,44 +277,53 @@ def _git_materialized_paths( for name in sorted(submodule_names): encoded_name = name.encode("utf-8", errors="surrogateescape") index_oid = submodule_names[name] - submodule_path = root / name - head_result = _git_bytes(root, "-C", name, "rev-parse", "--verify", "HEAD") - status_result = _git_bytes(root, "-C", name, "status", "--porcelain=v1", "-z") - head = head_result if head_result is not None else b"" - status = status_result if status_result is not None else b"" record: dict[str, Any] = { "path": encoded_name, "index_oid": index_oid, - "head": head.strip(), - "status": status, + "head": b"", + "status": b"", "files": 0, "content_digest": None, + "git_state_digest": None, } - try: - resolved_submodule = submodule_path.resolve(strict=True) - resolved_submodule.relative_to(root) - submodule_identity = os.path.normcase(str(resolved_submodule)) - if not resolved_submodule.is_dir(): - record["state"] = b"uninitialized" - elif submodule_identity in ancestors: - record["state"] = b"cycle" - else: - nested = _workspace_fingerprint(resolved_submodule, ancestors=ancestors) - record["state"] = ( - b"materialized-git" - if (resolved_submodule / ".git").exists() - else b"materialized-filesystem" - ) - record["content_digest"] = nested["content_digest"].encode("ascii") - record["files"] = nested["files"] - except (OSError, ValueError): - record["state"] = b"uninitialized-or-unsafe" + resolved_submodule = _materialized_submodule_root(root, name) + if resolved_submodule is None: + record["state"] = b"uninitialized" + submodules.append(record) + continue + head_result = _git_bytes( + resolved_submodule, + "rev-parse", + "--verify", + "HEAD", + ) + status_result = _git_bytes( + resolved_submodule, + "status", + "--porcelain=v1", + "-z", + ) + record["head"] = ( + head_result.strip() if head_result is not None else b"" + ) + record["status"] = ( + status_result if status_result is not None else b"" + ) + submodule_identity = os.path.normcase(str(resolved_submodule)) + if submodule_identity in ancestors: + record["state"] = b"cycle" + else: + nested = _workspace_fingerprint(resolved_submodule, ancestors=ancestors) + record["state"] = b"materialized-git" + record["content_digest"] = nested["content_digest"].encode("ascii") + record["git_state_digest"] = nested.get("git_state_digest") + record["files"] = nested["files"] submodules.append(record) return sorted(paths, key=lambda path: path.relative_to(root).as_posix()), submodules def _git_state_diagnostics(root: Path) -> tuple[str | None, str | None]: - """Return non-gating Git diagnostics for audit and troubleshooting.""" + """Return Git snapshot evidence used by quality integrity checks.""" head = _git_bytes(root, "rev-parse", "--verify", "HEAD") staged = _git_bytes( @@ -294,21 +357,146 @@ def _git_state_diagnostics(root: Path) -> tuple[str | None, str | None]: ) -def _git_bytes(root: Path, *arguments: str) -> bytes | None: +def _git_environment() -> dict[str, str]: + environment = { + name: value + for name, value in os.environ.items() + if not name.upper().startswith("GIT_") + } + environment["GIT_TERMINAL_PROMPT"] = "0" + return environment + + +def _aggregate_git_state_digest( + root_git_state_digest: str | None, + submodules: list[dict[str, Any]], +) -> str | None: + if root_git_state_digest is None and not submodules: + return None + digest = hashlib.sha256() + digest.update(b"anchorloop-git-state-tree-v1\0") + _update_digest_value( + digest, + b"root", + (root_git_state_digest or "").encode("ascii"), + ) + for submodule in submodules: + _update_digest_value(digest, b"submodule-path", submodule["path"]) + _update_digest_value(digest, b"submodule-index", submodule["index_oid"]) + _update_digest_value(digest, b"submodule-head", submodule["head"]) + _update_digest_value(digest, b"submodule-status", submodule["status"]) + nested = submodule.get("git_state_digest") + _update_digest_value( + digest, + b"submodule-nested-state", + (nested or "").encode("ascii"), + ) + return f"sha256:{digest.hexdigest()}" + + +def _run_git( + root: Path, + *arguments: str, + input_data: bytes | None = None, +) -> subprocess.CompletedProcess[bytes] | None: try: - process = subprocess.run( - ["git", *arguments], + return subprocess.run( + ["git", "--no-replace-objects", *arguments], cwd=root, capture_output=True, check=False, + env=_git_environment(), + input=input_data, ) except OSError: return None + + +def _git_bytes( + root: Path, + *arguments: str, + input_data: bytes | None = None, +) -> bytes | None: + process = _run_git(root, *arguments, input_data=input_data) + if process is None: + return None if process.returncode != 0: return None return process.stdout +def _validated_git_context( + root: Path, + *, + strict: bool, +) -> tuple[Path, str] | None: + top_level = _git_bytes(root, "rev-parse", "--show-toplevel") + if top_level is None: + if strict: + raise GitInspectionError( + "Required Git inspection failed: git rev-parse --show-toplevel" + ) + return None + prefix = _git_bytes(root, "rev-parse", "--show-prefix") + if prefix is None: + raise GitInspectionError( + "Required Git inspection failed: git rev-parse --show-prefix" + ) + try: + resolved_root = root.resolve(strict=True) + discovered = Path( + top_level.decode("utf-8", errors="surrogateescape").strip() + ).resolve(strict=True) + relative = resolved_root.relative_to(discovered) + except (OSError, RuntimeError, ValueError) as error: + raise GitInspectionError( + "Git reported a repository outside the inspected project context." + ) from error + expected_prefix = "" if relative == Path(".") else relative.as_posix().strip("/") + reported_prefix = prefix.decode( + "utf-8", + errors="surrogateescape", + ).replace("\\", "/").strip().strip("/") + if reported_prefix != expected_prefix: + raise GitInspectionError( + "Git reported a working-directory prefix that does not match the inspected project." + ) + _reject_legacy_git_grafts(root) + return discovered, reported_prefix + + +def _reject_legacy_git_grafts(root: Path) -> None: + graft_path = _git_bytes(root, "rev-parse", "--git-path", "info/grafts") + if graft_path is None: + raise GitInspectionError("Required Git graft inspection failed.") + decoded = graft_path.decode("utf-8", errors="surrogateescape").strip() + if not decoded: + raise GitInspectionError("Git reported an invalid legacy graft path.") + candidate = Path(decoded) + if not candidate.is_absolute(): + candidate = root / candidate + try: + metadata = os.lstat(candidate) + except FileNotFoundError: + return + except OSError as error: + raise GitInspectionError("Legacy Git graft state cannot be inspected safely.") from error + if ( + _is_windows_reparse_point(metadata) + or stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISREG(metadata.st_mode) + ): + raise GitInspectionError("Legacy Git graft state is unsafe.") + try: + contents = candidate.read_bytes() + except OSError as error: + raise GitInspectionError("Legacy Git graft state cannot be read safely.") from error + if contents.strip(): + raise GitInspectionError( + "Legacy Git grafts can rewrite commit ancestry and are not supported." + ) + + def _update_digest_value(digest: Any, label: bytes, value: bytes) -> None: digest.update(label) digest.update(b"\0") @@ -326,6 +514,10 @@ def _update_digest_with_file(digest: Any, root: Path, path: Path) -> None: _update_digest_value(digest, b"entry", b"unreadable") _update_digest_value(digest, b"path", relative_path) return + if _is_windows_reparse_point(metadata): + raise GitInspectionError( + f"Workspace fingerprint refuses Windows reparse point: {path}" + ) mode = str(stat.S_IMODE(metadata.st_mode)).encode("ascii") if stat.S_ISLNK(metadata.st_mode): @@ -375,12 +567,36 @@ def _update_digest_with_file(digest: Any, root: Path, path: Path) -> None: def _fingerprint_files(root: Path) -> list[Path]: - return [ - path - for path in root.rglob("*") - if _is_fingerprintable_path(path) - and not any(part in _SKIP_DIRECTORIES for part in path.relative_to(root).parts) - ] + files: list[Path] = [] + pending = [root] + while pending: + directory = pending.pop() + try: + entries = os.scandir(directory) + except OSError as error: + raise GitInspectionError( + f"Workspace fingerprint cannot inspect directory safely: {directory}" + ) from error + with entries: + for entry in entries: + if entry.name in _SKIP_DIRECTORIES: + continue + path = Path(entry.path) + try: + metadata = os.lstat(path) + except OSError as error: + raise GitInspectionError( + f"Workspace fingerprint cannot inspect path safely: {path}" + ) from error + if _is_windows_reparse_point(metadata): + raise GitInspectionError( + f"Workspace fingerprint refuses Windows reparse point: {path}" + ) + if stat.S_ISDIR(metadata.st_mode): + pending.append(path) + elif stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + files.append(path) + return files def _is_fingerprintable_path(path: Path) -> bool: @@ -388,6 +604,10 @@ def _is_fingerprintable_path(path: Path) -> bool: metadata = os.lstat(path) except OSError: return False + if _is_windows_reparse_point(metadata): + raise GitInspectionError( + f"Workspace fingerprint refuses Windows reparse point: {path}" + ) return stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode) @@ -405,29 +625,777 @@ def _is_anchor_owned_path(root: Path, path: Path) -> bool: return False -def _source_files(root: Path) -> list[Path]: - if (root / ".git").exists(): - changed = _git_changed_paths(root) - return [path for path in changed if _is_supported_source_file(root, path)] +def _source_documents( + root: Path, + *, + baseline: dict[str, Any] | None = None, +) -> list[tuple[str, str, bytes]]: + """Return candidate source contents from every publishable Git snapshot. + + A worktree-only scan is insufficient: a safe unstaged file can hide a bad + blob already committed to HEAD or staged in the index. Git-backed tasks + therefore scan baseline..HEAD blobs, index blobs, and current + unstaged/untracked files independently. Filesystem-backed tasks retain + their materialized-tree semantics because no historical blob store exists. + """ + + if isinstance(baseline, dict) and baseline.get("source") == "filesystem": + names = git_changed_path_names(root, baseline=baseline, strict=True) + documents = _worktree_source_documents(root, names) + git_present, head_exists = _filesystem_git_state(root, baseline) + if git_present: + documents.extend( + _git_source_documents( + root, + baseline=None, + include_head_snapshot=head_exists, + ) + ) + return documents - return [path for path in root.rglob("*") if _is_supported_source_file(root, path)] + if baseline is not None or _validated_git_context(root, strict=False) is not None: + # Perform the full baseline/submodule validation before reading blobs. + git_changed_path_names(root, baseline=baseline, strict=baseline is not None) + return _git_source_documents(root, baseline=baseline) + names = [ + path.relative_to(root).as_posix() + for path in root.rglob("*") + if _is_supported_source_file(root, path) + ] + return _worktree_source_documents(root, names) -def git_changed_path_names(root: Path, *, strict: bool = False) -> list[str]: - """Return normalized changed paths, including deleted files.""" - git_root = _git_bytes(root, "rev-parse", "--show-toplevel") - if git_root is None: - if strict: +def _git_source_documents( + root: Path, + *, + baseline: dict[str, Any] | None, + display_prefix: str = "", + include_head_snapshot: bool = False, +) -> list[tuple[str, str, bytes]]: + context = _validated_git_context(root, strict=baseline is not None) + if context is None: + return [] + repository_prefix = context[1] + candidates: list[tuple[tuple[str, ...], str, str | None]] = [ + ( + ( + "diff", + "--cached", + "--relative", + "--no-renames", + "--diff-filter=ACMRTUXB", + "--name-only", + "-z", + "--", + ), + "index", + "index", + ), + ( + ( + "diff", + "--relative", + "--no-renames", + "--diff-filter=ACMRTUXB", + "--name-only", + "-z", + "--", + ), + "worktree", + None, + ), + ( + ("ls-files", "--others", "--exclude-standard", "-z", "--"), + "worktree", + None, + ), + ] + if baseline is not None: + head = baseline.get("head") + if not isinstance(head, str) or re.fullmatch( + r"(?:[0-9a-f]{40}|[0-9a-f]{64})", head + ) is None: + raise GitInspectionError("The approved Git baseline commit is invalid.") + candidates.insert( + 0, + ( + ( + "diff", + "--relative", + "--no-renames", + "--diff-filter=ACMRTUXB", + "--name-only", + "-z", + head, + "HEAD", + "--", + ), + "HEAD", + "HEAD", + ), + ) + + documents: list[tuple[str, str, bytes]] = [] + seen: set[tuple[str, bytes]] = set() + for arguments, snapshot, git_object in candidates: + output = _git_bytes(root, *arguments) + if output is None: raise GitInspectionError( - "Required Git inspection failed: git rev-parse --show-toplevel" + "Required Git source inspection failed: git " + " ".join(arguments) ) + for name in _normalized_git_names(output): + if not _is_supported_source_name(name): + continue + display_name = f"{display_prefix}{name}" + if git_object is None: + path = root / name + if not _is_regular_file(path): + raise GitInspectionError( + f"Required worktree source disappeared during inspection: {display_name}" + ) + try: + content = path.read_bytes() + except OSError as error: + raise GitInspectionError( + f"Required worktree source is unreadable: {display_name}" + ) from error + else: + repository_name = ( + f"{repository_prefix}/{name}" if repository_prefix else name + ) + object_name = ( + f"HEAD:{repository_name}" + if git_object == "HEAD" + else f":0:{repository_name}" + ) + content = _git_bytes(root, "cat-file", "blob", object_name) + if content is None: + raise GitInspectionError( + f"Required Git {snapshot} blob is unreadable: {display_name}" + ) + dedupe_key = (display_name, hashlib.sha256(content).digest()) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + documents.append((display_name, snapshot, content)) + + if isinstance(baseline, dict): + submodules = baseline.get("submodules", {}) + if not isinstance(submodules, dict): + raise GitInspectionError("The approved Git submodule baseline is invalid.") + for name, nested_baseline in sorted(submodules.items()): + if not isinstance(nested_baseline, dict): + continue + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + raise GitInspectionError( + f"Git submodule baseline is unavailable after approval: {name}" + ) + documents.extend( + _git_source_documents( + nested_root, + baseline=nested_baseline, + display_prefix=f"{display_prefix}{name}/", + ) + ) + elif baseline is None: + if include_head_snapshot: + head = _git_bytes(root, "rev-parse", "--verify", "HEAD") + if head is None: + raise GitInspectionError("Required Git HEAD tree is unavailable.") + documents.extend( + _git_tree_source_documents( + root, + commit=head.decode("ascii").strip(), + display_prefix=display_prefix, + ancestors=frozenset(), + ) + ) + for name, oid in sorted(_gitlink_paths(root).items()): + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + raise GitInspectionError( + f"Git transition submodule is not materialized: {name}" + ) + documents.extend( + _git_tree_source_documents( + nested_root, + commit=oid, + display_prefix=f"{display_prefix}{name}/", + ancestors=frozenset(), + ) + ) + documents.extend( + _git_source_documents( + nested_root, + baseline=None, + display_prefix=f"{display_prefix}{name}/", + include_head_snapshot=True, + ) + ) + return documents + + +def _git_tree_source_documents( + root: Path, + *, + commit: str, + display_prefix: str, + ancestors: frozenset[str], +) -> list[tuple[str, str, bytes]]: + identity = os.path.normcase(str(root.resolve())) + if identity in ancestors: + raise GitInspectionError("Git submodule cycle detected during source inspection.") + context = _validated_git_context(root, strict=True) + if context is None or _git_bytes( + root, + "cat-file", + "-e", + f"{commit}^{{commit}}", + ) is None: + raise GitInspectionError("Required Git tree commit is unavailable.") + output = _git_bytes( + root, + "ls-tree", + "-r", + "--name-only", + "-z", + commit, + "--", + ".", + ) + if output is None: + raise GitInspectionError("Required Git tree source enumeration failed.") + repository_prefix = context[1] + documents: list[tuple[str, str, bytes]] = [] + for name in _normalized_git_names(output): + if not _is_supported_source_name(name): + continue + repository_name = f"{repository_prefix}/{name}" if repository_prefix else name + content = _git_bytes(root, "cat-file", "blob", f"{commit}:{repository_name}") + if content is None: + raise GitInspectionError( + f"Required Git tree blob is unreadable: {display_prefix}{name}" + ) + documents.append((f"{display_prefix}{name}", "HEAD", content)) + for name, oid in sorted(_gitlink_paths(root, treeish=commit).items()): + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + raise GitInspectionError( + f"Git tree submodule is not materialized: {display_prefix}{name}" + ) + documents.extend( + _git_tree_source_documents( + nested_root, + commit=oid, + display_prefix=f"{display_prefix}{name}/", + ancestors=ancestors | {identity}, + ) + ) + return documents + + +def _git_tree_path_names( + root: Path, + *, + commit: str, + display_prefix: str, + ancestors: frozenset[str], +) -> list[str]: + identity = os.path.normcase(str(root.resolve())) + if identity in ancestors: + raise GitInspectionError("Git submodule cycle detected during path inspection.") + if _validated_git_context(root, strict=True) is None or _git_bytes( + root, + "cat-file", + "-e", + f"{commit}^{{commit}}", + ) is None: + raise GitInspectionError("Required Git transition tree is unavailable.") + output = _git_bytes( + root, + "ls-tree", + "-r", + "--name-only", + "-z", + commit, + "--", + ".", + ) + if output is None: + raise GitInspectionError("Required Git transition path enumeration failed.") + names = [f"{display_prefix}{name}" for name in _normalized_git_names(output)] + for name, oid in sorted(_gitlink_paths(root, treeish=commit).items()): + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + raise GitInspectionError( + f"Git transition submodule is not materialized: {display_prefix}{name}" + ) + names.extend( + _git_tree_path_names( + nested_root, + commit=oid, + display_prefix=f"{display_prefix}{name}/", + ancestors=ancestors | {identity}, + ) + ) + return names + + +def _git_index_submodule_path_names( + root: Path, + *, + display_prefix: str, + ancestors: frozenset[str], +) -> list[str]: + identity = os.path.normcase(str(root.resolve())) + if identity in ancestors: + raise GitInspectionError("Git submodule cycle detected during index inspection.") + names: list[str] = [] + for name, oid in sorted(_gitlink_paths(root).items()): + nested_root = _materialized_submodule_root(root, name) + full_prefix = f"{display_prefix}{name}/" + if nested_root is None: + raise GitInspectionError( + f"Git transition submodule is not materialized: {display_prefix}{name}" + ) + names.extend( + _git_tree_path_names( + nested_root, + commit=oid, + display_prefix=full_prefix, + ancestors=ancestors | {identity}, + ) + ) + names.extend( + f"{full_prefix}{path}" + for path in git_changed_path_names( + nested_root, + baseline=None, + strict=True, + ) + ) + names.extend( + _git_index_submodule_path_names( + nested_root, + display_prefix=full_prefix, + ancestors=ancestors | {identity}, + ) + ) + return names + + +def _normalized_git_names(output: bytes) -> list[str]: + names: list[str] = [] + for encoded_name in output.split(b"\0"): + if not encoded_name: + continue + name = encoded_name.decode("utf-8", errors="surrogateescape").replace("\\", "/") + while name.startswith("./"): + name = name[2:] + parsed = Path(name) + if ( + not name + or name == ".anchor" + or name.startswith(".anchor/") + or parsed.is_absolute() + or bool(parsed.drive) + or bool(parsed.root) + or ".." in parsed.parts + ): + continue + names.append(name) + return names + + +def _worktree_source_documents( + root: Path, + names: list[str], +) -> list[tuple[str, str, bytes]]: + documents: list[tuple[str, str, bytes]] = [] + for name in sorted(set(names)): + path = root / name + if not _is_supported_source_file(root, path): + continue + try: + documents.append((name, "worktree", path.read_bytes())) + except OSError as error: + raise GitInspectionError( + f"Required worktree source is unreadable: {name}" + ) from error + return documents + + +def _is_supported_source_name(name: str) -> bool: + path = Path(name) + return ( + not any(part in _SKIP_DIRECTORIES for part in path.parts) + and (path.suffix.lower() in _TEXT_SUFFIXES or path.name.startswith(".env")) + ) + + +def task_change_baseline(root: Path) -> dict[str, Any]: + """Capture the material task baseline used by actual-diff gates. + + A Git commit is the compact, authoritative baseline when HEAD exists. An + unborn or non-Git workspace falls back to the same materialized-file + semantics used by workspace fingerprints, represented as per-path digests + so later gates can still identify risky paths. + """ + + try: + resolved_root = root.resolve(strict=True) + except OSError as error: + raise GitInspectionError( + "Required task baseline root inspection failed." + ) from error + return _task_change_baseline(resolved_root, ancestors=frozenset()) + + +def _task_change_baseline( + root: Path, + *, + ancestors: frozenset[str], +) -> dict[str, Any]: + identity = os.path.normcase(str(root.resolve())) + if identity in ancestors: + raise GitInspectionError("Git submodule cycle detected while capturing the task baseline.") + + git_context = _validated_git_context(root, strict=False) + if git_context is not None: + head = _git_bytes(root, "rev-parse", "--verify", "HEAD") + prefix = git_context[1] + if head: + head_text = head.decode("ascii", errors="strict").strip() + return _git_change_baseline_at_commit( + root, + head=head_text, + project_prefix=prefix, + ancestors=ancestors | {identity}, + ) + baseline = _filesystem_change_baseline(root) + baseline["git_context"] = "unborn" + baseline["git_unborn"] = True + baseline["project_prefix"] = prefix + return baseline + try: + baseline = _filesystem_change_baseline(root) + baseline["git_context"] = "none" + return baseline + except OSError as error: + raise GitInspectionError( + f"Required filesystem baseline inspection failed: {error}" + ) from error + + +def _filesystem_change_baseline(root: Path) -> dict[str, Any]: + entries: dict[str, str] = {} + total_bytes = 0 + for path in sorted( + _strict_task_baseline_files(root), + key=lambda candidate: candidate.relative_to(root).as_posix(), + ): + digest, measured_bytes = _strict_task_file_digest( + root, + path, + remaining_bytes=_TASK_BASELINE_MAX_BYTES - total_bytes, + ) + total_bytes += measured_bytes + entries[path.relative_to(root).as_posix()] = digest + return { + "source": "filesystem", + "format_version": 1, + "entries": entries, + "entry_count": len(entries), + "total_bytes": total_bytes, + } + + +def _strict_task_baseline_files(root: Path) -> list[Path]: + """Enumerate task-baseline files without the fingerprint's soft failures.""" + + files: list[Path] = [] + pending = [root] + inspected_entries = 0 + while pending: + directory = pending.pop() + # pathlib.rglob intentionally suppresses selected scandir errors on + # current Python releases. A security baseline must propagate them. + with os.scandir(directory) as entries: + for entry in entries: + inspected_entries += 1 + if inspected_entries > _TASK_BASELINE_MAX_ENTRIES: + raise OSError( + "Task baseline entry limit exceeded " + f"({_TASK_BASELINE_MAX_ENTRIES})." + ) + if entry.name in _SKIP_DIRECTORIES: + continue + path = Path(entry.path) + metadata = os.lstat(path) + if _is_windows_reparse_point(metadata): + raise OSError( + f"Task baseline refuses Windows reparse point: {path}" + ) + if stat.S_ISLNK(metadata.st_mode): + files.append(path) + elif stat.S_ISDIR(metadata.st_mode): + pending.append(path) + elif stat.S_ISREG(metadata.st_mode): + files.append(path) + return files + + +def _is_windows_reparse_point(metadata: os.stat_result) -> bool: + return os.name == "nt" and bool( + getattr(metadata, "st_file_attributes", 0) & _WINDOWS_REPARSE_POINT + ) + + +def _strict_task_file_digest( + root: Path, + path: Path, + *, + remaining_bytes: int, +) -> tuple[str, int]: + """Digest one baseline file, raising on every inspection/read failure.""" + + digest = hashlib.sha256() + digest.update(b"anchorloop-task-file-v1\0") + relative_path = path.relative_to(root).as_posix().encode( + "utf-8", + errors="surrogateescape", + ) + metadata = os.lstat(path) + if _is_windows_reparse_point(metadata): + raise OSError(f"Task baseline refuses Windows reparse point: {path}") + mode = str(stat.S_IMODE(metadata.st_mode)).encode("ascii") + if stat.S_ISLNK(metadata.st_mode): + target = os.readlink(path).encode("utf-8", errors="surrogateescape") + if len(target) > remaining_bytes: + raise OSError( + f"Task baseline byte limit exceeded ({_TASK_BASELINE_MAX_BYTES})." + ) + _update_digest_value(digest, b"entry", b"symlink") + _update_digest_value(digest, b"path", relative_path) + _update_digest_value(digest, b"mode", mode) + _update_digest_value(digest, b"target", target) + return f"sha256:{digest.hexdigest()}", len(target) + if not stat.S_ISREG(metadata.st_mode): + raise OSError(f"Task baseline path changed type while scanning: {path}") + + file_digest = hashlib.sha256() + size = 0 + if metadata.st_size > remaining_bytes: + raise OSError( + f"Task baseline byte limit exceeded ({_TASK_BASELINE_MAX_BYTES})." + ) + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or ( + opened.st_dev, + opened.st_ino, + ) != (metadata.st_dev, metadata.st_ino): + raise OSError("task baseline target changed while it was opened") + with os.fdopen(descriptor, "rb") as stream: + descriptor = -1 + while chunk := stream.read( + min(1024 * 1024, max(1, remaining_bytes - size + 1)) + ): + size += len(chunk) + if size > remaining_bytes: + raise OSError( + "Task baseline byte limit exceeded " + f"({_TASK_BASELINE_MAX_BYTES})." + ) + file_digest.update(chunk) + finally: + if descriptor >= 0: + os.close(descriptor) + + _update_digest_value(digest, b"entry", b"file") + _update_digest_value(digest, b"path", relative_path) + _update_digest_value(digest, b"mode", mode) + _update_digest_value(digest, b"size", str(size).encode("ascii")) + _update_digest_value(digest, b"content-sha256", file_digest.digest()) + return f"sha256:{digest.hexdigest()}", size + + +def _gitlink_paths(root: Path, *, treeish: str | None = None) -> dict[str, str]: + staged = ( + _git_bytes(root, "ls-files", "--stage", "-z", "--") + if treeish is None + else _git_bytes(root, "ls-tree", "-r", "-z", treeish, "--", ".") + ) + if staged is None: + raise GitInspectionError("Required Git submodule inspection failed: git ls-files") + gitlinks: dict[str, str] = {} + for record in staged.split(b"\0"): + if not record or b"\t" not in record: + continue + metadata, encoded_name = record.split(b"\t", 1) + fields = metadata.split() + if fields[:1] != [b"160000"]: + continue + oid_index = 1 if treeish is None else 2 + if len(fields) <= oid_index: + raise GitInspectionError("Required Git submodule record is malformed.") + name = encoded_name.decode( + "utf-8", + errors="surrogateescape", + ).replace("\\", "/") + while name.startswith("./"): + name = name[2:] + gitlinks[name] = fields[oid_index].decode("ascii", errors="strict") + return gitlinks + + +def _materialized_submodule_root(root: Path, name: str) -> Path | None: + candidate = root / Path(name) + try: + metadata = os.lstat(candidate) + except FileNotFoundError: + return None + except OSError as error: + raise GitInspectionError( + f"Git submodule path cannot be inspected safely: {name}" + ) from error + if ( + _is_windows_reparse_point(metadata) + or stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISDIR(metadata.st_mode) + ): + raise GitInspectionError(f"Git submodule path is unsafe: {name}") + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root.resolve()) + except (OSError, RuntimeError, ValueError) as error: + raise GitInspectionError(f"Git submodule path escapes the project: {name}") from error + nested_git_root = _git_bytes(candidate, "rev-parse", "--show-toplevel") + if nested_git_root is None: + return None + try: + discovered = Path( + nested_git_root.decode("utf-8", errors="surrogateescape").strip() + ).resolve(strict=True) + except OSError as error: + raise GitInspectionError(f"Git submodule root is unavailable: {name}") from error + if os.path.normcase(str(discovered)) != os.path.normcase(str(resolved)): + # An empty/uninitialized gitlink directory inherits the parent repo + # when Git searches upward; it is not a materialized submodule. + return None + return resolved + + +def _git_change_baseline_at_commit( + root: Path, + *, + head: str, + project_prefix: str, + ancestors: frozenset[str], +) -> dict[str, Any]: + gitlinks = _gitlink_paths(root, treeish=head) + baselines: dict[str, dict[str, Any] | None] = {} + for name, oid in sorted(gitlinks.items()): + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + baselines[name] = None + continue + identity = os.path.normcase(str(nested_root.resolve())) + if identity in ancestors: + raise GitInspectionError( + "Git submodule cycle detected while capturing the task baseline." + ) + nested_context = _validated_git_context(nested_root, strict=True) + if nested_context is None or _git_bytes( + nested_root, + "cat-file", + "-e", + f"{oid}^{{commit}}", + ) is None: + raise GitInspectionError( + f"Approved Git submodule commit is unavailable: {name}" + ) + baselines[name] = _git_change_baseline_at_commit( + nested_root, + head=oid, + project_prefix=nested_context[1], + ancestors=ancestors | {identity}, + ) + return { + "source": "git", + "format_version": 1, + "head": head, + "project_prefix": project_prefix, + "submodule_oids": gitlinks, + "submodules": baselines, + } + + +def git_changed_path_names( + root: Path, + *, + baseline: dict[str, Any] | None = None, + strict: bool = False, +) -> list[str]: + """Return normalized changed paths, including committed and deleted files. + + With a task baseline, Git repositories include ``baseline..HEAD`` plus + staged, unstaged, and untracked paths. Filesystem baselines compare the + current materialized per-path digests instead. + """ + + if baseline is not None and baseline.get("source") == "filesystem": + return _filesystem_changed_path_names(root, baseline, strict=strict) + + git_context = _validated_git_context(root, strict=strict) + if git_context is None: return [] - commands = ( + commands: tuple[tuple[str, ...], ...] = ( ("diff", "--relative", "--no-renames", "--name-only", "-z", "--"), ("diff", "--cached", "--relative", "--no-renames", "--name-only", "-z", "--"), ("ls-files", "--others", "--exclude-standard", "-z", "--"), ) + if baseline is not None: + if baseline.get("source") != "git": + raise GitInspectionError("The approved task change baseline is invalid.") + head = baseline.get("head") + if not isinstance(head, str) or re.fullmatch( + r"(?:[0-9a-f]{40}|[0-9a-f]{64})", head + ) is None: + raise GitInspectionError("The approved Git baseline commit is invalid.") + expected_prefix = baseline.get("project_prefix") + current_prefix = _git_bytes(root, "rev-parse", "--show-prefix") + if current_prefix is None: + raise GitInspectionError( + "Required Git inspection failed: git rev-parse --show-prefix" + ) + normalized_prefix = current_prefix.decode( + "utf-8", errors="surrogateescape" + ).replace("\\", "/").strip().strip("/") + if not isinstance(expected_prefix, str) or normalized_prefix != expected_prefix: + raise GitInspectionError( + "The approved Git baseline belongs to a different project path." + ) + if _git_bytes(root, "merge-base", "--is-ancestor", head, "HEAD") is None: + raise GitInspectionError( + "The approved Git baseline is no longer an ancestor of HEAD." + ) + commands = ( + ( + "diff", + "--relative", + "--no-renames", + "--name-only", + "-z", + head, + "HEAD", + "--", + ), + *commands, + ) paths: set[str] = set() for arguments in commands: output = _git_bytes(root, *arguments) @@ -447,13 +1415,175 @@ def git_changed_path_names(root: Path, *, strict: bool = False) -> list[str]: if not name or name == ".anchor" or name.startswith(".anchor/"): continue paths.add(name) + if baseline is not None: + expected_submodules = baseline.get("submodules", {}) + if not isinstance(expected_submodules, dict): + raise GitInspectionError("The approved Git submodule baseline is invalid.") + expected_submodule_oids = baseline.get("submodule_oids", {}) + if ( + not isinstance(expected_submodule_oids, dict) + or set(expected_submodule_oids) != set(expected_submodules) + ): + raise GitInspectionError("The approved Git submodule OIDs are invalid.") + current_submodules = _gitlink_paths(root) + if set(current_submodules) != set(expected_submodules): + raise GitInspectionError( + "The approved Git submodule set changed; revise and approve the plan again." + ) + for name, nested_baseline in sorted(expected_submodules.items()): + if not isinstance(name, str) or not name: + raise GitInspectionError("The approved Git submodule path is invalid.") + nested_root = _materialized_submodule_root(root, name) + if current_submodules[name] != expected_submodule_oids.get(name): + if nested_root is None: + raise GitInspectionError( + f"Git submodule commit changed without a materialized tree: {name}" + ) + current_head = _git_bytes( + nested_root, + "rev-parse", + "--verify", + "HEAD", + ) + if ( + current_head is None + or current_head.decode("ascii", errors="strict").strip() + != current_submodules[name] + ): + raise GitInspectionError( + f"Git submodule index and materialized commit disagree: {name}" + ) + if nested_baseline is None: + if nested_root is not None: + raise GitInspectionError( + f"Git submodule materialization changed after approval: {name}" + ) + continue + if not isinstance(nested_baseline, dict) or nested_root is None: + raise GitInspectionError( + f"Git submodule baseline is unavailable after approval: {name}" + ) + nested_paths = git_changed_path_names( + nested_root, + baseline=nested_baseline, + strict=True, + ) + paths.update(f"{name}/{nested_path}" for nested_path in nested_paths) return sorted(paths) -def _git_changed_paths(root: Path) -> list[Path]: +def _filesystem_changed_path_names( + root: Path, + baseline: dict[str, Any], + *, + strict: bool, +) -> list[str]: + entries = baseline.get("entries") + if ( + baseline.get("format_version") != 1 + or not isinstance(entries, dict) + or not all( + isinstance(path, str) and isinstance(digest, str) + for path, digest in entries.items() + ) + ): + if strict: + raise GitInspectionError("The approved filesystem baseline is invalid.") + return [] + try: + current = _filesystem_change_baseline(root)["entries"] + except OSError as error: + if strict: + raise GitInspectionError( + f"Required filesystem baseline inspection failed: {error}" + ) from error + return [] + changed = { + path + for path in set(entries) | set(current) + if entries.get(path) != current.get(path) + } + git_present, head_exists = _filesystem_git_state(root, baseline) + if git_present: + commands: list[tuple[str, ...]] = [ + ("diff", "--cached", "--relative", "--no-renames", "--name-only", "-z", "--"), + ("diff", "--relative", "--no-renames", "--name-only", "-z", "--"), + ("ls-files", "--others", "--exclude-standard", "-z", "--"), + ] + if head_exists: + commands.insert( + 0, + ("ls-tree", "-r", "--name-only", "-z", "HEAD", "--", "."), + ) + for arguments in commands: + output = _git_bytes(root, *arguments) + if output is None: + raise GitInspectionError( + "Required unborn Git inspection failed: git " + + " ".join(arguments) + ) + changed.update(_normalized_git_names(output)) + if head_exists: + head = _git_bytes(root, "rev-parse", "--verify", "HEAD") + if head is None: + raise GitInspectionError("Required Git transition HEAD is unavailable.") + changed.update( + _git_tree_path_names( + root, + commit=head.decode("ascii").strip(), + display_prefix="", + ancestors=frozenset(), + ) + ) + changed.update( + _git_index_submodule_path_names( + root, + display_prefix="", + ancestors=frozenset(), + ) + ) + return sorted(changed) + + +def _filesystem_git_state( + root: Path, + baseline: dict[str, Any], +) -> tuple[bool, bool]: + context_kind = baseline.get("git_context") + was_unborn = baseline.get("git_unborn") is True or context_kind == "unborn" + context = _validated_git_context(root, strict=was_unborn) + if context is None: + if was_unborn or (root / ".git").exists(): + raise GitInspectionError( + "The approved filesystem baseline Git context is unavailable." + ) + return False, False + if context_kind == "none": + raise GitInspectionError( + "Git was initialized after the filesystem task baseline was approved; " + "the task must restart from a Git-backed approval." + ) + if was_unborn: + expected_prefix = baseline.get("project_prefix") + if not isinstance(expected_prefix, str) or context[1] != expected_prefix: + raise GitInspectionError( + "The approved unborn Git baseline belongs to a different project path." + ) + return True, _git_bytes(root, "rev-parse", "--verify", "HEAD") is not None + + +def _git_changed_paths( + root: Path, + *, + baseline: dict[str, Any] | None = None, +) -> list[Path]: paths = [ root / relative_name - for relative_name in git_changed_path_names(root) + for relative_name in git_changed_path_names( + root, + baseline=baseline, + strict=baseline is not None, + ) ] return [path for path in paths if _is_regular_file(path)] @@ -464,30 +1594,35 @@ def _is_supported_source_file(root: Path, path: Path) -> bool: return path.suffix.lower() in _TEXT_SUFFIXES or path.name.startswith(".env") -def _python_syntax_failures(root: Path, files: list[Path]) -> list[dict[str, str]]: +def _python_syntax_failures( + documents: list[tuple[str, str, bytes]], +) -> list[dict[str, str]]: failures = [] - for path in files: - if path.suffix != ".py": + for name, snapshot, content in documents: + if Path(name).suffix != ".py": continue try: - compile(path.read_text(encoding="utf-8"), str(path), "exec") + text = content.decode("utf-8") + compile(text, name, "exec") except (SyntaxError, UnicodeDecodeError) as error: line = getattr(error, "lineno", None) failures.append( { "category": "syntax", - "location": _location(root, path, line), + "location": _snapshot_location(name, snapshot, line), "message": str(error), } ) return failures -def _secret_findings(root: Path, files: list[Path]) -> list[dict[str, str]]: +def _secret_findings( + documents: list[tuple[str, str, bytes]], +) -> list[dict[str, str]]: findings = [] - for path in files: + for name, snapshot, content in documents: try: - text = path.read_text(encoding="utf-8") + text = content.decode("utf-8") except UnicodeDecodeError: continue for line_number, line in enumerate(text.splitlines(), start=1): @@ -495,13 +1630,18 @@ def _secret_findings(root: Path, files: list[Path]) -> list[dict[str, str]]: findings.append( { "category": "secret", - "location": _location(root, path, line_number), + "location": _snapshot_location(name, snapshot, line_number), "message": "Possible hard-coded credential or private key.", } ) return findings +def _snapshot_location(name: str, snapshot: str, line: int | None = None) -> str: + location = name if snapshot == "worktree" else f"{name} [{snapshot}]" + return f"{location}:{line}" if line else location + + def _location(root: Path, path: Path, line: int | None = None) -> str: try: location = path.relative_to(root).as_posix() @@ -511,19 +1651,19 @@ def _location(root: Path, path: Path, line: int | None = None) -> str: def _git_whitespace_check(root: Path, arguments: tuple[str, ...], name: str) -> dict[str, str]: - if not (root / ".git").exists(): + if _validated_git_context(root, strict=False) is None: return {"name": name, "status": "not-run", "detail": "not a Git repository"} - process = subprocess.run( - ["git", *arguments], - cwd=root, - capture_output=True, - text=True, - check=False, - ) + process = _run_git(root, *arguments) + if process is None: + return {"name": name, "status": "failed", "detail": "Git is unavailable"} if process.returncode == 0: return {"name": name, "status": "passed"} return { "name": name, "status": "failed", - "detail": process.stdout.strip() or process.stderr.strip() or "git diff --check failed", + "detail": ( + process.stdout.decode("utf-8", errors="replace").strip() + or process.stderr.decode("utf-8", errors="replace").strip() + or "git diff --check failed" + ), } diff --git a/src/anchorloop/safe_fs.py b/src/anchorloop/safe_fs.py index 2a10b67..3afd0de 100644 --- a/src/anchorloop/safe_fs.py +++ b/src/anchorloop/safe_fs.py @@ -20,7 +20,14 @@ class SafeProjectFS: """ def __init__(self, root: Path) -> None: - self.root = root.resolve() + # Keep the spelling selected by the caller as well as the resolved + # boundary. Windows can expose the same directory through an 8.3 + # alias (for example RUNNER~1) while ``resolve()`` returns its long + # name. Callers may retain that original spelling when constructing + # descendants, so validate their lexical suffix against either known + # spelling and always operate beneath the resolved boundary. + self._selected_root = Path(os.path.abspath(root)) + self.root = self._selected_root.resolve() def path(self, *parts: str | Path) -> Path: candidate = self.root @@ -39,13 +46,16 @@ def validate(self, path: Path, *, require_exists: bool = False) -> Path: if candidate.drive or candidate.root: raise AnchorError("Managed path escapes the project root.") candidate = self.root / candidate - try: - relative = candidate.relative_to(self.root) - except ValueError as error: - raise AnchorError("Managed path escapes the project root.") from error + relative = self._relative_to_root(candidate) if ".." in relative.parts: raise AnchorError("Managed path escapes the project root.") + # Rebase descendants supplied through the selected spelling onto the + # immutable resolved boundary. This does not dereference descendant + # links; _validate_existing_components() still rejects every symlink + # or reparse-point below the boundary before any managed operation. + candidate = self.root / relative + self._validate_existing_components(relative) try: resolved = candidate.resolve(strict=False) @@ -57,6 +67,14 @@ def validate(self, path: Path, *, require_exists: bool = False) -> Path: raise AnchorError(f"Managed path does not exist: {candidate}") return candidate + def _relative_to_root(self, candidate: Path) -> Path: + for root in (self.root, self._selected_root): + try: + return candidate.relative_to(root) + except ValueError: + continue + raise AnchorError("Managed path escapes the project root.") + def exists(self, path: Path) -> bool: candidate = self.validate(path) return self._lstat(candidate) is not None diff --git a/tests/test_actual_diff_risk.py b/tests/test_actual_diff_risk.py index be844fd..bc2bd9b 100644 --- a/tests/test_actual_diff_risk.py +++ b/tests/test_actual_diff_risk.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re import subprocess import tempfile @@ -9,6 +10,7 @@ from unittest import mock from anchorloop.project import AnchorError, AnchorProject +from anchorloop.quality import GitInspectionError, workspace_fingerprint BRIEF = { @@ -30,6 +32,15 @@ def _git(self, root: Path, *arguments: str) -> None: text=True, ) + def _git_output(self, root: Path, *arguments: str) -> str: + return subprocess.run( + ["git", *arguments], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + def _init_git(self, root: Path) -> None: self._git(root, "init", "--quiet") self._git(root, "config", "user.name", "AnchorLoop Tests") @@ -101,9 +112,12 @@ def _implementing_project( *, mode: str = "STANDARD", override: str | None = None, + active_security: bool = False, ) -> AnchorProject: project = AnchorProject.at(root) project.apply_setup("add") + if active_security: + project.approve_rule("baseline-security-v1", by="Ada Engineer") project.start_task("Protect the actual implementation diff") project.record_brief(by="Ada Engineer", values=BRIEF) self._plan(project, mode=mode, override=override) @@ -173,6 +187,855 @@ def test_careful_review_allows_risk_paths(self) -> None: reviewed = project.transition("review") self.assertEqual(reviewed["state"], "review_ready") + def test_committed_auth_after_approval_blocks_review(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + self._commit_file( + root, + "auth/permissions.py", + "ALLOW_ADMIN = False\n", + ) + + with self.assertRaisesRegex( + AnchorError, + r"auth/permissions\.py", + ): + project.transition("review") + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + self.assertNotIn("quality", task) + + def test_git_replace_cannot_hide_committed_sensitive_change(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + baseline = task["change_baseline"]["head"] + self._commit_file( + root, + "auth/permissions.py", + "ALLOW_ADMIN = False\n", + ) + current_tree = self._git_output(root, "rev-parse", "HEAD^{tree}") + replacement = self._git_output(root, "commit-tree", current_tree) + self._git(root, "replace", baseline, replacement) + + with self.assertRaisesRegex( + AnchorError, + r"auth/permissions\.py", + ): + project.transition("review") + + def test_inherited_git_environment_cannot_divert_diff_inspection(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + clean = base / "clean" + self._git( + base, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(root), + str(clean), + ) + project = self._implementing_project(root) + self._commit_file( + root, + "auth/permissions.py", + "ALLOW_ADMIN = False\n", + ) + + with ( + mock.patch.dict( + os.environ, + { + "GIT_DIR": str(clean / ".git"), + "GIT_WORK_TREE": str(clean), + }, + clear=False, + ), + self.assertRaisesRegex( + AnchorError, + r"auth/permissions\.py", + ), + ): + project.transition("review") + + def test_legacy_git_grafts_block_required_diff_inspection(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + head = self._git_output(root, "rev-parse", "HEAD") + grafts = root / ".git" / "info" / "grafts" + grafts.parent.mkdir(parents=True, exist_ok=True) + grafts.write_text(f"{head}\n", encoding="utf-8") + + with self.assertRaisesRegex(AnchorError, "grafts"): + project.transition("review") + + def test_committed_python_syntax_error_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + self._commit_file(root, "src/broken.py", "def broken(:\n") + (root / "src" / "broken.py").write_text( + "def broken():\n return None\n", + encoding="utf-8", + ) + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertEqual(task["quality"][-1]["status"], "blocked") + self.assertTrue( + any( + finding["location"].startswith("src/broken.py") + for finding in task["quality"][-1]["findings"] + ) + ) + + def test_committed_secret_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + self._commit_file( + root, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + (root / "src" / "config.py").write_text( + "API_KEY = None\n", + encoding="utf-8", + ) + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertTrue( + any( + finding["category"] == "secret" + and finding["location"].startswith("src/config.py") + for finding in task["quality"][-1]["findings"] + ) + ) + + def test_staged_secret_cannot_be_masked_by_safe_unstaged_content(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + config = root / "src" / "config.py" + config.parent.mkdir(parents=True) + config.write_text('API_KEY = "abcdefgh-secret-value"\n', encoding="utf-8") + self._git(root, "add", "--", "src/config.py") + config.write_text("API_KEY = None\n", encoding="utf-8") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertTrue( + any( + finding["category"] == "secret" + and "[index]" in finding["location"] + for finding in task["quality"][-1]["findings"] + ) + ) + + def test_unborn_git_head_secret_cannot_be_masked_by_baseline_worktree(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + config = root / "src" / "config.py" + config.parent.mkdir(parents=True) + config.write_text("API_KEY = None\n", encoding="utf-8") + project = self._implementing_project(root, active_security=True) + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertTrue(task["change_baseline"]["git_unborn"]) + + config.write_text('API_KEY = "abcdefgh-secret-value"\n', encoding="utf-8") + self._git(root, "add", "--", "src/config.py") + self._git(root, "commit", "--quiet", "-m", "malicious first commit") + config.write_text("API_KEY = None\n", encoding="utf-8") + project.transition("review") + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + def test_git_initialized_after_non_git_approval_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / "src" / "config.py" + config.parent.mkdir(parents=True) + config.write_text("API_KEY = None\n", encoding="utf-8") + project = self._implementing_project(root, active_security=True) + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertEqual(task["change_baseline"]["git_context"], "none") + + self._init_git(root) + config.write_text('API_KEY = "abcdefgh-secret-value"\n', encoding="utf-8") + self._git(root, "add", "--", "src/config.py") + self._git(root, "commit", "--quiet", "-m", "malicious first commit") + config.write_text("API_KEY = None\n", encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "Git was initialized after"): + project.transition("review") + + def test_staged_submodule_secret_before_approval_uses_head_gitlink_baseline(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + source_head = self._git_output(source, "rev-parse", "HEAD") + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + source_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "add library") + library = root / "vendor" / "library" + self._git(library, "config", "user.name", "AnchorLoop Tests") + self._git( + library, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + config = library / "src" / "config.py" + config.write_text('API_KEY = "abcdefgh-secret-value"\n', encoding="utf-8") + self._git(library, "add", "--", "src/config.py") + self._git(library, "commit", "--quiet", "-m", "secret update") + self._git(root, "add", "--", "vendor/library") + config.write_text("API_KEY = None\n", encoding="utf-8") + + project = self._implementing_project(root, active_security=True) + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertNotEqual( + task["change_baseline"]["submodule_oids"]["vendor/library"], + self._git_output(library, "rev-parse", "HEAD"), + ) + project.transition("review") + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + def test_unborn_git_first_commit_recursively_scans_submodule_tree(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "auth/permissions.py", "ALLOW_ADMIN = True\n") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + library = root / "vendor" / "library" + self._git(library, "config", "user.name", "AnchorLoop Tests") + self._git( + library, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + project = self._implementing_project(root, active_security=True) + permissions = library / "auth" / "permissions.py" + permissions.write_text("ALLOW_ADMIN = False\n", encoding="utf-8") + self._git(library, "add", "--", "auth/permissions.py") + self._git(library, "commit", "--quiet", "-m", "permissions update") + changed_head = self._git_output(library, "rev-parse", "HEAD") + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + changed_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "first parent commit") + permissions.write_text("ALLOW_ADMIN = True\n", encoding="utf-8") + + with self.assertRaisesRegex( + AnchorError, + r"vendor/library/auth/permissions\.py", + ): + project.transition("review") + + def test_quality_invalidates_masked_git_state_after_precommit(self) -> None: + cases = ("staged-root", "committed-nested") + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + self._init_git(repository) + root = repository if case == "staged-root" else repository / "nested" + config_relative = ( + "src/config.py" + if root == repository + else "nested/src/config.py" + ) + config = self._commit_file(repository, config_relative, "API_KEY = None\n") + project = self._implementing_project(root) + project.transition("review") + project.precommit() + + config.write_text('API_KEY = "abcdefgh-secret-value"\n', encoding="utf-8") + self._git(repository, "add", "--", config_relative) + config.write_text("API_KEY = None\n", encoding="utf-8") + if case == "committed-nested": + self._git(repository, "commit", "--quiet", "-m", "masked secret") + with self.assertRaisesRegex(AnchorError, "Code changed after the quality gate"): + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted retry state prevents duplicate acknowledgement.", + ) + + def test_committed_whitespace_error_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + self._commit_file(root, "src/retry.py", "ATTEMPTS = 3 \n") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertTrue( + any( + finding["category"] == "whitespace" + for finding in task["quality"][-1]["findings"] + ) + ) + + def test_committed_sensitive_change_inside_submodule_blocks_review(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "secure-library" + source.mkdir() + self._init_git(source) + self._commit_file( + source, + "auth/permissions.py", + "ALLOW_ADMIN = True\n", + ) + + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/secure-library", + ) + source_head = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=source, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + (root / ".gitmodules").write_text( + "[submodule \"vendor/secure-library\"]\n" + "\tpath = vendor/secure-library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + source_head, + "vendor/secure-library", + ) + self._git(root, "commit", "--quiet", "-m", "add secure library") + project = self._implementing_project(root) + + nested = root / "vendor" / "secure-library" + self._git(nested, "config", "user.name", "AnchorLoop Tests") + self._git( + nested, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + permissions = nested / "auth" / "permissions.py" + permissions.write_text("ALLOW_ADMIN = False\n", encoding="utf-8") + self._git(nested, "add", "--", "auth/permissions.py") + self._git(nested, "commit", "--quiet", "-m", "tighten permissions") + + with self.assertRaisesRegex( + AnchorError, + r"vendor/secure-library/auth/permissions\.py", + ): + project.transition("review") + + def test_uninitialized_submodule_can_rebaseline_after_plan_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "library" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/library.py", "VALUE = 1\n") + source_head = self._git_output(source, "rev-parse", "HEAD") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + source_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "add uninitialized library") + project = self._implementing_project(root) + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertIsNone(task["change_baseline"]["submodules"]["vendor/library"]) + + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + with self.assertRaisesRegex(AnchorError, "materialization changed"): + project.transition("review") + + revised = project.revise_task( + target="plan", + reason="Materialize the approved submodule before implementation.", + ) + self.assertIsNone( + revised["change_baseline"]["submodules"]["vendor/library"] + ) + self._plan(project, mode="STANDARD") + approved = project.approve_task("Ada Engineer") + self.assertEqual( + approved["change_baseline"]["submodules"]["vendor/library"]["source"], + "git", + ) + project.transition("implement") + self.assertEqual(project.transition("review")["state"], "review_ready") + + def test_filesystem_baseline_capture_fails_closed_on_read_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + risky = root / "auth" / "permissions.py" + risky.parent.mkdir(parents=True) + risky.write_text("ALLOW_ADMIN = True\n", encoding="utf-8") + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Protect the actual implementation diff") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + real_open = os.open + risky_key = os.path.normcase(os.path.abspath(risky)) + + def guarded_open(path: object, *args: object, **kwargs: object) -> int: + candidate = os.path.normcase(os.path.abspath(os.fspath(path))) + if candidate == risky_key: + raise PermissionError("simulated baseline read denial") + return real_open(path, *args, **kwargs) # type: ignore[arg-type] + + with ( + mock.patch("anchorloop.quality.os.open", side_effect=guarded_open), + self.assertRaisesRegex( + AnchorError, + "filesystem baseline inspection failed", + ), + ): + project.approve_task("Ada Engineer") + + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "planned") + + def test_filesystem_baseline_capture_fails_closed_on_lstat_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + risky = root / "auth" / "permissions.py" + risky.parent.mkdir(parents=True) + risky.write_text("ALLOW_ADMIN = True\n", encoding="utf-8") + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Protect the actual implementation diff") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + real_lstat = os.lstat + risky_key = os.path.normcase(os.path.abspath(risky)) + + def guarded_lstat(path: object, *args: object, **kwargs: object) -> object: + candidate = os.path.normcase(os.path.abspath(os.fspath(path))) + if candidate == risky_key: + raise PermissionError("simulated baseline lstat denial") + return real_lstat(path, *args, **kwargs) # type: ignore[arg-type] + + with ( + mock.patch("anchorloop.quality.os.lstat", side_effect=guarded_lstat), + self.assertRaisesRegex( + AnchorError, + "filesystem baseline inspection failed", + ), + ): + project.approve_task("Ada Engineer") + + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "planned") + + def test_filesystem_baseline_capture_fails_closed_on_scandir_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + risky_directory = root / "auth" + risky_directory.mkdir() + (risky_directory / "permissions.py").write_text( + "ALLOW_ADMIN = True\n", + encoding="utf-8", + ) + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Protect the actual implementation diff") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + real_scandir = os.scandir + risky_key = os.path.normcase(os.path.abspath(risky_directory)) + + def guarded_scandir(path: object) -> object: + candidate = os.path.normcase(os.path.abspath(os.fspath(path))) + if candidate == risky_key: + raise PermissionError("simulated baseline directory denial") + return real_scandir(path) + + with ( + mock.patch( + "anchorloop.quality.os.scandir", + side_effect=guarded_scandir, + ), + self.assertRaisesRegex( + AnchorError, + "filesystem baseline inspection failed", + ), + ): + project.approve_task("Ada Engineer") + + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "planned") + + def test_filesystem_baseline_entry_limit_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "one.py").write_text("ONE = 1\n", encoding="utf-8") + (root / "two.py").write_text("TWO = 2\n", encoding="utf-8") + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Bound filesystem baseline entries") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + with ( + mock.patch("anchorloop.quality._TASK_BASELINE_MAX_ENTRIES", 1), + self.assertRaisesRegex(AnchorError, "entry limit exceeded"), + ): + project.approve_task("Ada Engineer") + + def test_filesystem_baseline_directory_only_entry_limit_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for index in range(3): + (root / f"empty-{index}").mkdir() + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Bound filesystem baseline directory traversal") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + with ( + mock.patch("anchorloop.quality._TASK_BASELINE_MAX_ENTRIES", 2), + self.assertRaisesRegex(AnchorError, "entry limit exceeded"), + ): + project.approve_task("Ada Engineer") + + def test_filesystem_baseline_byte_limit_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "payload.bin").write_bytes(b"12345") + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Bound filesystem baseline bytes") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + with ( + mock.patch("anchorloop.quality._TASK_BASELINE_MAX_BYTES", 4), + self.assertRaisesRegex(AnchorError, "byte limit exceeded"), + ): + project.approve_task("Ada Engineer") + + @unittest.skipUnless(os.name == "nt", "Windows junctions are required") + def test_filesystem_baseline_blocks_windows_junction(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + root = base / "project" + outside = base / "outside" + root.mkdir() + outside.mkdir() + (outside / "permissions.py").write_text( + "ALLOW_ADMIN = True\n", + encoding="utf-8", + ) + junction = root / "auth" + created = subprocess.run( + [ + "cmd.exe", + "/d", + "/c", + "mklink", + "/J", + str(junction), + str(outside), + ], + capture_output=True, + text=True, + check=False, + ) + if created.returncode != 0: + self.skipTest(f"junction creation unavailable: {created.stderr}") + + project = AnchorProject.at(root) + project.apply_setup("add") + project.start_task("Reject filesystem junctions") + project.record_brief(by="Ada Engineer", values=BRIEF) + self._plan(project, mode="STANDARD") + + with self.assertRaisesRegex(AnchorError, "reparse point"): + project.approve_task("Ada Engineer") + + @unittest.skipUnless(os.name == "nt", "Windows junctions are required") + def test_workspace_fingerprint_blocks_filesystem_junction(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + root = base / "project" + outside = base / "outside" + root.mkdir() + outside.mkdir() + (outside / "secret.py").write_text("VALUE = 1\n", encoding="utf-8") + junction = root / "linked" + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(junction), str(outside)], + capture_output=True, + text=True, + check=False, + ) + if created.returncode != 0: + self.skipTest(f"junction creation unavailable: {created.stderr}") + with self.assertRaisesRegex(GitInspectionError, "reparse point"): + workspace_fingerprint(root) + + @unittest.skipUnless(os.name == "nt", "Windows junctions are required") + def test_workspace_fingerprint_blocks_git_tracked_path_replaced_by_junction(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + root = base / "project" + outside = base / "outside" + root.mkdir() + outside.mkdir() + self._init_git(root) + tracked = self._commit_file(root, "linked", "tracked file\n") + tracked.unlink() + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(tracked), str(outside)], + capture_output=True, + text=True, + check=False, + ) + if created.returncode != 0: + self.skipTest(f"junction creation unavailable: {created.stderr}") + with self.assertRaisesRegex(GitInspectionError, "reparse point"): + workspace_fingerprint(root) + + def test_safe_committed_change_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + self._commit_file(root, "src/retry.py", "ATTEMPTS = 3\n") + + reviewed = project.transition("review") + + self.assertEqual(reviewed["state"], "review_ready") + + def test_approval_subject_and_digest_bind_change_baseline(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + + subject = project._task_approval_subject(task) + + self.assertEqual(subject["change_baseline"], task["change_baseline"]) + self.assertEqual(task["change_baseline"]["source"], "git") + self.assertEqual( + task["approval"]["task_digest"], + project._document_digest(subject), + ) + + def test_rewritten_approved_git_baseline_blocks_review(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + baseline_path = self._commit_file(root, "README.md") + project = self._implementing_project(root) + baseline_path.write_text("rewritten history\n", encoding="utf-8") + self._git(root, "add", "--", "README.md") + self._git(root, "commit", "--quiet", "--amend", "--no-edit") + + with self.assertRaisesRegex( + AnchorError, + "approved Git baseline is no longer an ancestor", + ): + project.transition("review") + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "implementing") + + def test_rewritten_baseline_blocks_even_in_careful_mode(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + baseline_path = self._commit_file(root, "README.md") + project = self._implementing_project(root, mode="CAREFUL") + baseline_path.write_text("rewritten history\n", encoding="utf-8") + self._git(root, "add", "--", "README.md") + self._git(root, "commit", "--quiet", "--amend", "--no-edit") + + with self.assertRaisesRegex( + AnchorError, + "approved Git baseline is no longer an ancestor", + ): + project.transition("review") + + def test_committed_rename_reports_both_old_and_new_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "auth/permissions.py") + project = self._implementing_project(root) + self._git( + root, + "mv", + "--", + "auth/permissions.py", + "auth/access_control.py", + ) + self._git(root, "commit", "--quiet", "-m", "rename auth module") + + with self.assertRaises(AnchorError) as raised: + project.transition("review") + + message = str(raised.exception) + self.assertIn("auth/permissions.py", message) + self.assertIn("auth/access_control.py", message) + def test_staged_rename_reports_the_sensitive_source_path(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -195,11 +1058,12 @@ def test_git_inspection_failure_blocks_review(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) self._init_git(root) + self._commit_file(root, "README.md") project = self._implementing_project(root) with ( mock.patch("anchorloop.quality._git_bytes", return_value=None), - self.assertRaisesRegex(AnchorError, "Cannot evaluate the actual Git diff safely"), + self.assertRaisesRegex(AnchorError, "Cannot evaluate the actual diff safely"), ): project.transition("review") task = json.loads( @@ -207,14 +1071,25 @@ def test_git_inspection_failure_blocks_review(self) -> None: ) self.assertEqual(task["state"], "implementing") - def test_non_git_project_blocks_lower_mode_review(self) -> None: + def test_non_git_project_uses_filesystem_baseline(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) project = self._implementing_project(root) + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual( + task["change_baseline"]["source"], + "filesystem", + ) + risky = root / "auth" / "permissions.py" + risky.parent.mkdir(parents=True) + risky.write_text("ALLOW_ADMIN = False\n", encoding="utf-8") + with self.assertRaisesRegex( AnchorError, - "Cannot evaluate the actual Git diff safely", + r"auth/permissions\.py", ): project.transition("review") task = json.loads( @@ -260,6 +1135,73 @@ def test_preapproved_path_override_still_requires_a_revision_cycle(self) -> None ) self.assertEqual(task["state"], "implementing") + def test_historical_revision_does_not_preapprove_future_risk_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + auth = root / "auth" / "permissions.py" + auth.parent.mkdir(parents=True) + auth.write_text("ALLOW_ADMIN = False\n", encoding="utf-8") + with self.assertRaisesRegex(AnchorError, r"auth/permissions\.py"): + project.transition("review") + + revised = project.revise_task( + target="plan", + reason="Actual diff introduced CAREFUL risk paths.", + ) + self.assertEqual( + revised["revisions"][-1]["actual_diff_risk_paths"], + ["auth/permissions.py"], + ) + override = ( + "Engineer reviewed actual diff risk paths: auth/permissions.py " + "and payments/new.py" + ) + self._plan(project, mode="STANDARD", override=override) + approved = project.approve_task("Ada Engineer") + self.assertEqual( + approved["approval"]["task_digest"], + project._document_digest(project._task_approval_subject(approved)), + ) + project.transition("implement") + project.transition("review") + + payment = root / "payments" / "new.py" + payment.parent.mkdir(parents=True) + payment.write_text("ENABLED = False\n", encoding="utf-8") + with self.assertRaisesRegex(AnchorError, r"payments/new\.py"): + project.precommit() + + def test_actual_diff_revision_evidence_is_approval_bound(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + risky = root / "auth" / "permissions.py" + risky.parent.mkdir(parents=True) + risky.write_text("ALLOW_ADMIN = False\n", encoding="utf-8") + with self.assertRaises(AnchorError): + project.transition("review") + project.revise_task( + target="plan", + reason="Actual diff introduced CAREFUL risk paths.", + ) + self._plan( + project, + mode="STANDARD", + override="Engineer reviewed actual diff risk path: auth/permissions.py", + ) + project.approve_task("Ada Engineer") + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + task["revisions"][-1]["actual_diff_risk_paths"].append("payments/new.py") + project.active_task_path.write_text(json.dumps(task), encoding="utf-8") + + with self.assertRaisesRegex(AnchorError, "approved plan"): + project.transition("implement") + def test_lower_mode_explicit_override_is_approval_bound(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -298,6 +1240,64 @@ def test_lower_mode_explicit_override_is_approval_bound(self) -> None: reviewed = project.transition("review") self.assertEqual(reviewed["state"], "review_ready") + def test_precommit_rejects_git_metadata_change_during_snapshot_scan(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + project.transition("review") + starting = workspace_fingerprint(root) + ending = { + **starting, + "git_state_digest": "sha256:" + "0" * 64, + } + with ( + mock.patch( + "anchorloop.quality.workspace_fingerprint", + side_effect=(starting, ending), + ), + self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"), + ): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertEqual(task["quality"][-1]["status"], "blocked") + self.assertTrue( + any( + finding["category"] == "workspace" + for finding in task["quality"][-1]["findings"] + ) + ) + + def test_plan_revision_preserves_original_diff_baseline(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + original = json.loads(project.active_task_path.read_text(encoding="utf-8"))[ + "change_baseline" + ]["head"] + self._commit_file( + root, + "auth/permissions.py", + "ALLOW_ADMIN = False\n", + ) + with self.assertRaisesRegex(AnchorError, r"auth/permissions\.py"): + project.transition("review") + + revised = project.revise_task( + target="plan", + reason="Actual diff introduced CAREFUL risk paths.", + ) + self.assertEqual(revised["change_baseline"]["head"], original) + self._plan(project, mode="STANDARD") + approved = project.approve_task("Ada Engineer") + self.assertEqual(approved["change_baseline"]["head"], original) + project.transition("implement") + with self.assertRaisesRegex(AnchorError, r"auth/permissions\.py"): + project.transition("review") + def test_anchor_only_changes_are_ignored(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -337,6 +1337,33 @@ def test_risk_added_after_review_blocks_precommit_without_quality( self.assertNotIn("quality", task) self.assertEqual(risky.read_bytes(), before) + def test_committed_migration_after_review_blocks_precommit_without_quality( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root) + reviewed = project.transition("review") + self.assertEqual(reviewed["state"], "review_ready") + self._commit_file( + root, + "migrations/0042.sql", + "ALTER TABLE events ADD COLUMN attempts INTEGER;\n", + ) + + with self.assertRaisesRegex( + AnchorError, + r"migrations/0042\.sql", + ): + project.precommit() + task = json.loads( + project.active_task_path.read_text(encoding="utf-8") + ) + self.assertEqual(task["state"], "review_ready") + self.assertNotIn("quality", task) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index b88a0e5..ae5eb31 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -518,7 +518,7 @@ def test_git_workspace_fingerprint_ignores_anchor_state(self) -> None: task = json.loads((root / ".anchor" / "tasks" / "active.json").read_text()) self.assertEqual(task["state"], "verified") - def test_git_commit_does_not_invalidate_identical_materialized_tree(self) -> None: + def test_git_commit_invalidates_quality_even_when_worktree_is_identical(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) tracked = root / "tracked.py" @@ -569,15 +569,16 @@ def test_git_commit_does_not_invalidate_identical_materialized_tree(self) -> Non "--reason", "The checked content is unchanged by the commit.", "--recall", - "A metadata-only commit does not change the checked files.", + "A commit changes the authoritative Git snapshot.", "--path", str(root), ] ), - 0, + 2, ) after = json.loads((root / ".anchor" / "tasks" / "active.json").read_text()) - self.assertEqual(after["state"], "verified") + self.assertEqual(after["state"], "review_ready") + self.assertIn("quality_invalidations", after) self.assertEqual(before["digest"], before["content_digest"]) def test_git_submodule_worktree_changes_invalidate_materialized_fingerprint(self) -> None: diff --git a/tests/test_core_validation.py b/tests/test_core_validation.py index 635a235..ee780c9 100644 --- a/tests/test_core_validation.py +++ b/tests/test_core_validation.py @@ -1,12 +1,15 @@ from __future__ import annotations +import io import json import math import tempfile import unittest +from contextlib import redirect_stdout from datetime import UTC, datetime, timedelta from pathlib import Path +from anchorloop.cli import _print_experiment_report from anchorloop.project import AnchorError, AnchorProject from tests.git_fixture import init_git_repository @@ -59,6 +62,23 @@ def _project_ready_for_verification( project.precommit() return project + def _closed_project( + self, + root: Path, + *, + mode: str = "STANDARD", + ) -> tuple[AnchorProject, dict[str, object], Path]: + project = self._project_ready_for_verification(root, mode=mode) + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted retry state prevents duplicate acknowledgement.", + ) + closed = project.transition("close") + closed_path = root / ".anchor" / "tasks" / "closed" / f"{closed['id']}.json" + return project, closed, closed_path + def test_public_record_brief_requires_exact_complete_text_fields(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -279,6 +299,256 @@ def test_legacy_careful_recall_record_remains_usable(self) -> None: ) self.assertEqual(recalled["comprehension"]["delayed_recall"]["score"], 4) + legacy_observations = json.loads( + closed_path.read_text(encoding="utf-8") + ) + legacy_observations["comprehension"]["delayed_recall"].pop( + "record_digest" + ) + closed_path.write_text( + json.dumps(legacy_observations), + encoding="utf-8", + ) + project.record_post_completion_outcome( + task_id=task_id, + by="Ada Engineer", + defects_found=1, + rollback=False, + corrective_refactor=True, + notes="A legacy follow-up observation remains readable.", + ) + legacy_observations = json.loads( + closed_path.read_text(encoding="utf-8") + ) + legacy_observations["post_completion_outcomes"][-1].pop( + "record_digest" + ) + closed_path.write_text( + json.dumps(legacy_observations), + encoding="utf-8", + ) + + report = project.experiment_report() + row = report["tasks"][0] + self.assertEqual( + row["delayed_recall_integrity"], + "legacy-unverified", + ) + self.assertEqual( + row["latest_outcome_integrity"], + "legacy-unverified", + ) + self.assertEqual(row["legacy_unverified_observations"], 2) + self.assertEqual( + report["summary"]["legacy_unverified_observations"], + 2, + ) + csv_output = io.StringIO() + with redirect_stdout(csv_output): + _print_experiment_report(report, output_format="csv") + csv_text = csv_output.getvalue() + header = csv_text.splitlines()[0] + self.assertIn("delayed_recall_integrity", header) + self.assertIn("latest_outcome_integrity", header) + self.assertIn("legacy_unverified_observations", header) + self.assertEqual(csv_text.count("legacy-unverified"), 2) + + def test_current_post_close_records_require_record_digests(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._project_ready_for_verification(root, mode="CAREFUL") + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted retry state prevents duplicate acknowledgement.", + ) + closed = project.transition("close") + closed_path = ( + root / ".anchor" / "tasks" / "closed" / f"{closed['id']}.json" + ) + due_at = closed["comprehension"]["recall_due_at"] + closed["comprehension"]["delayed_recall"] = { + "statement": "The retry boundary remains the key trade-off.", + "score": 4, + "by": "Ada Engineer", + "due_at": due_at, + "recorded_at": due_at, + "delay_seconds": 0.0, + } + closed_path.write_text(json.dumps(closed), encoding="utf-8") + with self.assertRaisesRegex( + AnchorError, + "Protected delayed recall records require a record digest", + ): + project.experiment_report() + + closed["comprehension"].pop("delayed_recall") + closed["post_completion_outcomes"] = [ + { + "at": closed["metrics"]["closed_at"], + "by": "Ada Engineer", + "defects_found": 0, + "rollback": False, + "corrective_refactor": False, + "notes": "No follow-up defect was observed.", + "provenance": "reported-post-completion", + } + ] + closed_path.write_text(json.dumps(closed), encoding="utf-8") + with self.assertRaisesRegex( + AnchorError, + "Protected post-completion outcome records require a record digest", + ): + project.experiment_report() + + def test_schema_v2_without_post_close_discriminator_remains_readable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project, closed, closed_path = self._closed_project(root) + recorded = project.record_post_completion_outcome( + task_id=str(closed["id"]), + by="Ada Engineer", + defects_found=0, + rollback=False, + corrective_refactor=False, + notes="Historical schema-v2 observation.", + ) + legacy = json.loads(json.dumps(recorded)) + legacy.pop("post_close_integrity") + outcome = legacy["post_completion_outcomes"][0] + for field in ( + "record_digest", + "record_type", + "task_id", + "sequence", + "previous_digest", + ): + outcome.pop(field) + legacy["close_digest"] = project._closed_task_digest(legacy) + closed_path.write_text(json.dumps(legacy), encoding="utf-8") + + report = project.experiment_report() + self.assertEqual(report["tasks"][0]["latest_outcome_integrity"], "legacy-unverified") + self.assertEqual(report["summary"]["legacy_unverified_observations"], 1) + + def test_post_close_chain_rejects_replay_reorder_delete_and_duplicate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project, closed, closed_path = self._closed_project(root) + for index in range(2): + project.record_post_completion_outcome( + task_id=str(closed["id"]), + by="Ada Engineer", + defects_found=index, + rollback=False, + corrective_refactor=False, + notes=f"Follow-up observation {index + 1}.", + ) + pristine = json.loads(closed_path.read_text(encoding="utf-8")) + + def replay_from_another_task(task: dict[str, object]) -> None: + record = task["post_completion_outcomes"][1] # type: ignore[index] + record["task_id"] = "al-20000101-deadbe" + record["record_digest"] = project._record_digest(record) + task["post_close_integrity"]["head_digest"] = record["record_digest"] # type: ignore[index] + + tamper_cases = { + "replay": replay_from_another_task, + "reorder": lambda task: task["post_completion_outcomes"].reverse(), + "delete": lambda task: task["post_completion_outcomes"].pop(0), + "duplicate": lambda task: task["post_completion_outcomes"].__setitem__( + 1, json.loads(json.dumps(task["post_completion_outcomes"][0])) + ), + } + for name, tamper in tamper_cases.items(): + with self.subTest(name=name): + candidate = json.loads(json.dumps(pristine)) + tamper(candidate) + closed_path.write_text(json.dumps(candidate), encoding="utf-8") + with self.assertRaises(AnchorError): + project.experiment_report() + closed_path.write_text(json.dumps(pristine), encoding="utf-8") + self.assertEqual( + project.experiment_report()["tasks"][0]["latest_outcome_integrity"], + "chain-verified", + ) + + def test_delayed_recall_record_rejects_digest_preserving_type_tampering(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._project_ready_for_verification(root, mode="CAREFUL") + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted retry state prevents duplicate acknowledgement.", + ) + active_path = root / ".anchor" / "tasks" / "active.json" + legacy = json.loads(active_path.read_text(encoding="utf-8")) + due_at = datetime.now(UTC) - timedelta(minutes=1) + legacy["comprehension"].pop("recall_delay_hours") + legacy["comprehension"]["recall_due_at"] = due_at.isoformat() + legacy.pop("schema_version") + for field in ( + "subject_digest", + "reported_metrics", + "comprehension_digest", + "record_digest", + ): + legacy["verification"].pop(field) + legacy["approval"].pop("record_digest") + legacy["approval"].update( + project._task_approval_digests(project._task_approval_subject(legacy)) + ) + active_path.write_text(json.dumps(legacy), encoding="utf-8") + + closed = project.transition("close") + closed_path = root / ".anchor" / "tasks" / "closed" / f"{closed['id']}.json" + recorded = project.record_delayed_recall( + task_id=closed["id"], + by="Ada Engineer", + response="The retry boundary remains the key trade-off.", + score=2, + ) + recall_record = recorded["comprehension"]["delayed_recall"] + self.assertRegex(recall_record["record_digest"], r"^sha256:[0-9a-f]{64}$") + + tampered = json.loads(closed_path.read_text(encoding="utf-8")) + tampered["comprehension"]["delayed_recall"]["score"] = 5 + closed_path.write_text(json.dumps(tampered), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "delayed recall record digest"): + project.experiment_report() + + def test_post_completion_outcome_rejects_digest_preserving_type_tampering(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = self._project_ready_for_verification(root) + project.verify_task( + by="Ada Engineer", + result="pass", + reason="The documented acceptance scenario passed.", + recall="Persisted retry state prevents duplicate acknowledgement.", + ) + closed = project.transition("close") + closed_path = root / ".anchor" / "tasks" / "closed" / f"{closed['id']}.json" + recorded = project.record_post_completion_outcome( + task_id=closed["id"], + by="Ada Engineer", + defects_found=0, + rollback=False, + corrective_refactor=False, + notes="No follow-up defect was observed.", + ) + outcome = recorded["post_completion_outcomes"][-1] + self.assertRegex(outcome["record_digest"], r"^sha256:[0-9a-f]{64}$") + + tampered = json.loads(closed_path.read_text(encoding="utf-8")) + tampered["post_completion_outcomes"][-1]["defects_found"] = 9 + closed_path.write_text(json.dumps(tampered), encoding="utf-8") + with self.assertRaisesRegex(AnchorError, "post-completion outcome record digest"): + project.experiment_report() + def test_task_schema_rejects_manually_malformed_brief_before_transition(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_filesystem_safety.py b/tests/test_filesystem_safety.py index 9d3cda5..bb901a8 100644 --- a/tests/test_filesystem_safety.py +++ b/tests/test_filesystem_safety.py @@ -50,6 +50,54 @@ def test_new_file_mode_policy_distinguishes_state_and_portable_assets(self) -> N portable_mode, ) + def test_managed_path_accepts_the_original_root_spelling_after_resolution(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + canonical_root = base / "canonical-project" + selected_root = base / "selected-project" + canonical_root.mkdir() + self._symlink_or_skip(canonical_root, selected_root, directory=True) + filesystem = SafeProjectFS(selected_root) + + filesystem.atomic_write_text(selected_root / "managed.txt", "managed\n") + + self.assertEqual( + (canonical_root / "managed.txt").read_text(encoding="utf-8"), + "managed\n", + ) + + @unittest.skipUnless(os.name == "nt", "Windows 8.3 path aliases are required") + def test_managed_path_accepts_windows_short_name_root_alias(self) -> None: + import ctypes + + with tempfile.TemporaryDirectory() as directory: + canonical_root = Path(directory) / "canonical-project-directory" + canonical_root.mkdir() + get_short_path_name = ctypes.WinDLL( + "kernel32", + use_last_error=True, + ).GetShortPathNameW + buffer = ctypes.create_unicode_buffer(32768) + length = get_short_path_name(str(canonical_root), buffer, len(buffer)) + if length == 0 or length >= len(buffer): + self.skipTest("Windows short-path aliases are unavailable on this volume") + selected_root = Path(buffer.value) + if os.path.normcase(str(selected_root)) == os.path.normcase(str(canonical_root)): + self.skipTest("Windows did not assign a distinct short-path alias") + filesystem = SafeProjectFS(selected_root) + outside = canonical_root.parent / "outside.txt" + outside.write_text("outside\n", encoding="utf-8") + + filesystem.atomic_write_text(selected_root / "managed.txt", "managed\n") + + self.assertEqual( + (canonical_root / "managed.txt").read_text(encoding="utf-8"), + "managed\n", + ) + with self.assertRaisesRegex(AnchorError, "escapes the project root"): + filesystem.atomic_write_text(selected_root / ".." / "outside.txt", "changed\n") + self.assertEqual(outside.read_text(encoding="utf-8"), "outside\n") + @unittest.skipUnless(os.name == "posix", "POSIX permission semantics are required") def test_atomic_write_preserves_existing_repository_file_permissions(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 62af3ce..19d9de9 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -1,11 +1,42 @@ import json import pathlib +import re import tomllib import unittest from anchorloop.version import VERSION +def _workflow_job(workflow: str, job_name: str) -> str: + lines = workflow.splitlines() + marker = f" {job_name}:" + try: + start = lines.index(marker) + except ValueError as error: + raise AssertionError(f"Workflow job {job_name!r} is missing") from error + end = len(lines) + for index in range(start + 1, len(lines)): + if re.fullmatch(r" [A-Za-z0-9_-]+:", lines[index]): + end = index + break + return "\n".join(lines[start:end]) + + +def _workflow_step(job: str, step_name: str) -> str: + lines = job.splitlines() + marker = f" - name: {step_name}" + try: + start = lines.index(marker) + except ValueError as error: + raise AssertionError(f"Workflow step {step_name!r} is missing") from error + end = len(lines) + for index in range(start + 1, len(lines)): + if lines[index].startswith(" - "): + end = index + break + return "\n".join(lines[start:end]) + + class ReleaseVersionTests(unittest.TestCase): def test_python_and_npm_packaging_use_the_canonical_version(self) -> None: root = pathlib.Path(__file__).resolve().parents[1] @@ -21,6 +52,244 @@ def test_python_and_npm_packaging_use_the_canonical_version(self) -> None: self.assertEqual(package["version"], VERSION) self.assertIn("src/anchorloop/version.py", package["files"]) + def test_release_workflow_stages_with_oidc_and_keeps_promotion_human_owned(self) -> None: + root = pathlib.Path(__file__).resolve().parents[1] + workflow = (root / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + finalizer = (root / "npm" / "scripts" / "finalize-release-docs.js").read_text( + encoding="utf-8" + ) + + exact_tag_ci = _workflow_job(workflow, "exact-tag-ci") + publish = _workflow_job(workflow, "publish-npm") + verify = _workflow_job(workflow, "verify-public-registry") + + self.assertIn(" if: github.event_name == 'push'", exact_tag_ci) + self.assertIn(" uses: ./.github/workflows/ci.yml", exact_tag_ci) + self.assertIn(" if: github.event_name == 'push'", publish) + self.assertIn(" needs: exact-tag-ci", publish) + self.assertIn(" environment: npm-release", publish) + self.assertIn( + " permissions:\n contents: read\n id-token: write", + publish, + ) + self.assertIn(" if: github.event_name == 'workflow_dispatch'", verify) + self.assertIn(" permissions:\n contents: read", verify) + self.assertNotIn("id-token: write", verify) + + signed_tag = _workflow_step( + publish, + "Require a GitHub-verified signed annotated tag", + ) + ancestry = _workflow_step( + publish, + "Require tagged commit to be merged into main", + ) + prepare_docs = _workflow_step( + publish, + "Prepare time-invariant release artifact documentation", + ) + verify_docs = _workflow_step( + publish, + "Verify release artifact documentation and mutation boundary", + ) + package_preview = _workflow_step( + publish, + "Dry-run package assembly from the finalized checkout", + ) + stage = _workflow_step( + publish, + "Stage candidate with npm provenance through trusted publishing", + ) + handoff = _workflow_step(publish, "Record the human approval handoff") + + self.assertIn(".verification.verified", signed_tag) + self.assertIn('test "$target_sha" = "$(git rev-parse HEAD)"', signed_tag) + self.assertIn( + 'git merge-base --is-ancestor "$tagged_commit" refs/remotes/origin/main', + ancestry, + ) + for required_file in ( + "README.md", + "CHANGELOG.md", + "SECURITY.md", + "CONTRIBUTING.md", + "docs/MIGRATION_0.2.md", + "docs/ANCHOR_DECISION_MAP.md", + "docs/PORTABLE_SKILL.md", + "docs/PROJECT_PLAN.md", + "docs/i18n/README.de.md", + "docs/i18n/README.es.md", + "docs/i18n/README.fr.md", + "docs/i18n/README.ja.md", + "docs/i18n/README.pt-BR.md", + "docs/i18n/README.ru.md", + "docs/i18n/README.zh-CN.md", + ): + self.assertIn(required_file, finalizer) + self.assertIn("git show -s --format=%cs HEAD", prepare_docs) + self.assertIn("npm/scripts/finalize-release-docs.js", prepare_docs) + self.assertIn('--release-date "$release_date"', prepare_docs) + self.assertIn("npm/scripts/finalize-release-docs.js", verify_docs) + self.assertIn("--check", verify_docs) + self.assertIn("CONTRIBUTING.md", verify_docs) + self.assertIn("git diff --cached --quiet --", verify_docs) + self.assertIn("git diff --cached --name-only --", verify_docs) + self.assertIn("git ls-files --others --exclude-standard -z", verify_docs) + self.assertIn("Unexpected untracked release path", verify_docs) + for protected_path in ( + "src npm package.json package-lock.json pyproject.toml", + "Unexpected release artifact mutation", + ): + self.assertIn(protected_path, " ".join(verify_docs.split())) + self.assertLess( + verify_docs.index("git diff --cached --quiet --"), + verify_docs.index("git ls-files --others --exclude-standard -z"), + ) + self.assertLess( + verify_docs.index("git ls-files --others --exclude-standard -z"), + verify_docs.index("git diff --exit-code --"), + ) + self.assertNotIn("Require final release documentation", publish) + self.assertIn("release[- ]candidate", finalizer) + self.assertIn("published production", finalizer) + self.assertIn("raw.githubusercontent.com/ppmarkek/AnchorLoop/v${version}/", finalizer) + self.assertIn("npm pack --dry-run --ignore-scripts", package_preview) + self.assertIn("npm stage publish .", " ".join(stage.split())) + self.assertIn("--ignore-scripts", stage) + self.assertNotIn("steps.archive.outputs.path", stage) + self.assertNotRegex(stage, r"npm stage publish\s+.*\.tgz") + self.assertIn("--tag next", stage) + self.assertIn("exact staged artifact", handoff) + self.assertIn("npm stage approve ", handoff) + self.assertIn( + "gh workflow run release.yml --ref v${VERSION} -f version=${VERSION}", + handoff, + ) + self.assertLess(publish.index(signed_tag), publish.index(ancestry)) + self.assertLess(publish.index(ancestry), publish.index(prepare_docs)) + self.assertLess(publish.index(prepare_docs), publish.index(verify_docs)) + self.assertLess(publish.index(verify_docs), publish.index(package_preview)) + self.assertLess(publish.index(package_preview), publish.index(stage)) + self.assertLess(publish.index(stage), publish.index(handoff)) + + requested_version = _workflow_step( + verify, + "Verify requested version and exact release tag", + ) + revalidated_tag = _workflow_step( + verify, + "Revalidate GitHub-verified signed annotated tag", + ) + verify_ancestry = _workflow_step( + verify, + "Require tagged commit to remain in main", + ) + approved_candidate = _workflow_step( + verify, + "Require exact version to be the approved next candidate", + ) + registry_smoke = _workflow_step( + verify, + "Smoke-test the exact public registry release", + ) + commit_identity = _workflow_step(verify, "Verify published commit identity") + promotion = _workflow_step(verify, "Record the human-owned promotion command") + + self.assertIn(".verification.verified", revalidated_tag) + self.assertIn('expected_ref="refs/tags/v${REQUESTED_VERSION}"', requested_version) + self.assertIn('test "$GITHUB_REF" = "$expected_ref"', requested_version) + self.assertIn( + "gh workflow run release.yml --ref v${REQUESTED_VERSION} -f version=${REQUESTED_VERSION}", + requested_version, + ) + self.assertIn('test "$object_type" = "tag"', revalidated_tag) + self.assertIn('test "$target_type" = "commit"', revalidated_tag) + self.assertIn('test "$target_sha" = "$(git rev-parse HEAD)"', revalidated_tag) + self.assertIn("git merge-base --is-ancestor", verify_ancestry) + self.assertIn('npm view "${PACKAGE_NAME}@next" version', approved_candidate) + self.assertIn("node npm/scripts/registry-smoke.js", registry_smoke) + self.assertIn('npm view "${PACKAGE_NAME}@${VERSION}" gitHead', commit_identity) + self.assertIn("npm dist-tag add ${PACKAGE_NAME}@${VERSION} latest", promotion) + self.assertIn("Promotion remains an interactive maintainer action with 2FA", promotion) + self.assertIn("post-promotion documentation PR against main", promotion) + self.assertIn("status surface and its release-document tests", promotion) + self.assertIn("${VERSION} candidate to ${VERSION} production", promotion) + self.assertIn("finalizer source fragments and source-RC assertions", promotion) + self.assertIn("v${VERSION} artifact unchanged", promotion) + self.assertLess(verify.index(requested_version), verify.index(revalidated_tag)) + self.assertLess(verify.index(revalidated_tag), verify.index(verify_ancestry)) + self.assertLess(verify.index(verify_ancestry), verify.index(approved_candidate)) + self.assertLess(verify.index(approved_candidate), verify.index(registry_smoke)) + self.assertLess(verify.index(registry_smoke), verify.index(commit_identity)) + self.assertLess(verify.index(commit_identity), verify.index(promotion)) + + self.assertNotRegex(workflow, r"(?m)^\s+npm publish(?:\s|$)") + self.assertNotRegex(workflow, r"(?m)^\s+npm dist-tag add(?:\s|$)") + self.assertNotIn("NODE_AUTH_TOKEN", workflow) + self.assertNotIn("secrets.", workflow) + + def test_release_documentation_stays_candidate_until_external_gates_complete(self) -> None: + root = pathlib.Path(__file__).resolve().parents[1] + readme = (root / "README.md").read_text(encoding="utf-8") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + security = (root / "SECURITY.md").read_text(encoding="utf-8") + contributing = (root / "CONTRIBUTING.md").read_text(encoding="utf-8") + migration = (root / "docs" / "MIGRATION_0.2.md").read_text(encoding="utf-8") + decision_map = (root / "docs" / "ANCHOR_DECISION_MAP.md").read_text( + encoding="utf-8" + ) + project_plan = (root / "docs" / "PROJECT_PLAN.md").read_text(encoding="utf-8") + normalized_decision_map = " ".join(decision_map.split()) + translations = sorted((root / "docs" / "i18n").glob("README.*.md")) + + self.assertIn("**Published production:** `anchorloop@0.1.0`", readme) + self.assertIn("**Unreleased main:** `0.2.0` release candidate", readme) + self.assertNotIn("**Published production:** `anchorloop@0.2.0`", readme) + self.assertIn('src="docs/assets/anchorloop-delivery-loop.svg"', readme) + self.assertIn('src="docs/assets/anchorloop-evidence-integrity.svg"', readme) + self.assertNotIn("raw.githubusercontent.com/ppmarkek/AnchorLoop/main/", readme) + self.assertIn("deterministic materialized-file fallback", readme) + self.assertIn("## 0.2.0 - Unreleased", changelog) + self.assertIn("anchorloop@0.1.0", security) + self.assertIn("0.2.0", security) + self.assertIn("unreleased release", security.lower()) + self.assertIn("anchorloop@0.1.0` remains the published production baseline", contributing) + self.assertIn("stage-only OIDC", contributing) + self.assertIn("approves it with 2FA", contributing) + self.assertIn("tag-bound exact registry smoke", " ".join(contributing.split())) + self.assertIn("`latest` manually with 2FA", contributing) + self.assertIn("deterministically transforms only declared packaged documentation", contributing) + self.assertIn("post-promotion documentation PR against `main`", contributing) + self.assertIn("to `0.2.0` production", contributing) + self.assertIn("finalized Git checkout directory", contributing) + self.assertIn("directory-derived `gitHead`", " ".join(contributing.split())) + self.assertIn("Both the dry-run and staging disable lifecycle", contributing) + self.assertIn("with lifecycle scripts disabled", contributing) + self.assertIn("finalizer source fragments and source-RC assertions", contributing) + self.assertNotIn("publishes it with npm provenance through OIDC", contributing) + self.assertNotIn("exact-source", contributing) + self.assertIn("- Published production: `anchorloop@0.1.0`", migration) + self.assertIn( + "- Current release branch: unreleased `0.2.0` release candidate", + migration, + ) + old_strict = "npx --yes anchorloop@0.1.0 doctor --strict" + old_repair = "npx --yes anchorloop@0.1.0 doctor --repair" + new_install = "npx --yes anchorloop@0.2.0 install" + self.assertLess(migration.index(old_strict), migration.index(old_repair)) + self.assertLess(migration.index(old_repair), migration.index(new_install)) + self.assertIn("stage-only `npm stage publish`", normalized_decision_map) + self.assertIn("stores no npm token", normalized_decision_map) + self.assertNotIn("`anchorloop@0.2.0` is published production", decision_map) + self.assertIn("deterministic materialized-file fallback", project_plan) + for translation in translations: + banner = "\n".join(translation.read_text(encoding="utf-8").splitlines()[:10]) + self.assertIn("anchorloop@0.1.0", banner, translation.name) + self.assertIn("`0.2.0`", banner, translation.name) + self.assertNotRegex(banner, r"\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b", translation.name) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_skill_install_matrix.py b/tests/test_skill_install_matrix.py index 43584a6..911bb9a 100644 --- a/tests/test_skill_install_matrix.py +++ b/tests/test_skill_install_matrix.py @@ -56,7 +56,7 @@ def _exercise_installation( home = base / "home" project.mkdir() expected_root = project if project_scoped else home - expected = expected_root.joinpath(*location, "skills", "anchorloop") + expected = expected_root.resolve().joinpath(*location, "skills", "anchorloop") with mock.patch("anchorloop.skill_install.Path.home", return_value=home): installer = SkillInstaller(project) From 099b00554a9473e107ec32e342e3d1376bcdb500 Mon Sep 17 00:00:00 2001 From: pppmarkek <153396079+ppmarkek@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:35:36 -0700 Subject: [PATCH 5/5] fix: harden release history checks Scan reachable source history for reverted secrets, bind submodule scans to committed gitlinks, and make Windows baseline-mock tests portable. --- README.md | 2 +- npm/scripts/finalize-release-docs.js | 2 +- src/anchorloop/quality.py | 601 ++++++++++++++++++++++++++- tests/test_actual_diff_risk.py | 583 +++++++++++++++++++++++++- tests/test_release_version.py | 2 +- 5 files changed, 1174 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ede816d..72a6eb7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ AnchorLoop lets an AI agent implement code without taking ownership away from th **Published production:** `anchorloop@0.1.0` -**Unreleased main:** `0.2.0` release candidate +**Unreleased candidate:** `0.2.0` release candidate The published `0.1.0` package remains the production version. The current release branch contains the unreleased recovery, validation, ownership, diff --git a/npm/scripts/finalize-release-docs.js b/npm/scripts/finalize-release-docs.js index b68370d..fffd937 100644 --- a/npm/scripts/finalize-release-docs.js +++ b/npm/scripts/finalize-release-docs.js @@ -169,7 +169,7 @@ function releaseDocumentSpecs(version, releaseDate) { source: lines( `**Published production:** \`${previousPackage}\``, "", - `**Unreleased main:** \`${version}\` release candidate`, + `**Unreleased candidate:** \`${version}\` release candidate`, "", `The published \`${PREVIOUS_PRODUCTION_VERSION}\` package remains the production version. The current`, "release branch contains the unreleased recovery, validation, ownership,", diff --git a/src/anchorloop/quality.py b/src/anchorloop/quality.py index 91d4808..837c09f 100644 --- a/src/anchorloop/quality.py +++ b/src/anchorloop/quality.py @@ -5,6 +5,7 @@ import re import stat import subprocess +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -17,6 +18,10 @@ ) _TASK_BASELINE_MAX_ENTRIES = 100_000 _TASK_BASELINE_MAX_BYTES = 512 * 1024 * 1024 +_HISTORY_SCAN_MAX_OBJECTS = 100_000 +_HISTORY_SCAN_MAX_COMMITS = 1_000 +_HISTORY_SCAN_MAX_BYTES = 64 * 1024 * 1024 +_GIT_OBJECT_ID = re.compile(rb"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") _WINDOWS_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) @@ -24,6 +29,12 @@ class GitInspectionError(RuntimeError): """A required task-baseline query could not be completed safely.""" +@dataclass +class _HistoryScanBudget: + objects: int = 0 + bytes: int = 0 + + def run_precommit( root: Path, *, @@ -53,7 +64,9 @@ def run_precommit( } ) else: - secret_findings = _secret_findings(documents) + secret_findings = _secret_findings( + _security_source_documents(root, baseline=baseline, documents=documents) + ) if secret_findings: findings.extend(secret_findings) checks.append({"name": "secret-baseline", "status": "failed"}) @@ -666,6 +679,592 @@ def _source_documents( return _worktree_source_documents(root, names) +def _security_source_documents( + root: Path, + *, + baseline: dict[str, Any] | None, + documents: list[tuple[str, str, bytes]], +) -> list[tuple[str, str, bytes]]: + """Add each new historical source blob once for the security scan only. + + Syntax and ownership checks intentionally retain their material-diff + semantics. A credential is different: even a reverted blob is reachable + from the branch and would be pushed, so security must inspect the complete + approved-baseline-to-HEAD history. + """ + + historical = _git_history_source_documents( + root, + baseline=baseline, + budget=_HistoryScanBudget(), + ) + if not historical: + return documents + known_contents = { + hashlib.sha256(content).digest() + for _, _, content in documents + } + combined = list(documents) + for document in historical: + content_digest = hashlib.sha256(document[2]).digest() + if content_digest in known_contents: + continue + known_contents.add(content_digest) + combined.append(document) + return combined + + +def _git_history_source_documents( + root: Path, + *, + baseline: dict[str, Any] | None, + display_prefix: str = "", + ancestors: frozenset[str] = frozenset(), + budget: _HistoryScanBudget, +) -> list[tuple[str, str, bytes]]: + """Return source blobs newly reachable after an approved Git baseline.""" + + if not isinstance(baseline, dict): + return [] + source = baseline.get("source") + if source == "filesystem": + # An unborn repository has no commit object to bind the approval to. + # Once it has a HEAD, inspect all reachable objects rather than letting + # a first-commit secret disappear behind a later committed revert. + git_present, head_exists = _filesystem_git_state(root, baseline) + if not git_present or not head_exists: + return [] + context = _validated_git_context(root, strict=True) + if context is None: + raise GitInspectionError("Required Git history context is unavailable.") + head = _git_bytes(root, "rev-parse", "--verify", "HEAD") + if head is None: + raise GitInspectionError("Required Git history HEAD is unavailable.") + return _git_full_history_source_documents( + root, + commit=head.decode("ascii", errors="strict").strip(), + display_prefix=display_prefix, + ancestors=ancestors, + budget=budget, + ) + if source != "git": + return [] + + identity = os.path.normcase(str(root.resolve())) + if identity in ancestors: + raise GitInspectionError("Git submodule cycle detected during history inspection.") + context = _validated_git_context(root, strict=True) + if context is None: + raise GitInspectionError("Required Git history context is unavailable.") + baseline_head = baseline.get("head") + if not isinstance(baseline_head, str) or re.fullmatch( + r"(?:[0-9a-f]{40}|[0-9a-f]{64})", baseline_head + ) is None: + raise GitInspectionError("The approved Git baseline commit is invalid.") + expected_prefix = baseline.get("project_prefix") + if not isinstance(expected_prefix, str) or context[1] != expected_prefix: + raise GitInspectionError( + "The approved Git baseline belongs to a different project path." + ) + current_head = _git_bytes(root, "rev-parse", "--verify", "HEAD") + if current_head is None: + raise GitInspectionError("Required Git history HEAD is unavailable.") + current_head_text = current_head.decode("ascii", errors="strict").strip() + if _git_bytes(root, "merge-base", "--is-ancestor", baseline_head, current_head_text) is None: + raise GitInspectionError("The approved Git baseline is no longer an ancestor of HEAD.") + submodules = baseline.get("submodules", {}) + if not isinstance(submodules, dict): + raise GitInspectionError("The approved Git submodule baseline is invalid.") + approved_gitlinks = baseline.get("submodule_oids", {}) + if ( + not isinstance(approved_gitlinks, dict) + or set(approved_gitlinks) != set(submodules) + or not all( + isinstance(object_id, str) + and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", object_id) + is not None + for object_id in approved_gitlinks.values() + ) + ): + raise GitInspectionError("The approved Git submodule OIDs are invalid.") + current_gitlinks = _gitlink_paths(root, treeish=current_head_text) + if set(current_gitlinks) != set(submodules): + raise GitInspectionError( + "The approved Git submodule set changed in parent history." + ) + + allowed_gitlinks: dict[str, str] = {} + materialized_submodules: dict[str, tuple[Path, dict[str, Any]]] = {} + for name, nested_baseline in sorted(submodules.items()): + approved_gitlink = approved_gitlinks[name] + current_gitlink = current_gitlinks[name] + if nested_baseline is None: + # An unmaterialized child has no baseline repository we can scan. + # It must therefore remain pinned to its approved gitlink; accepting + # a parent-tree advance would let a secret be introduced and reverted + # entirely inside the unavailable child history. + if current_gitlink != approved_gitlink: + raise GitInspectionError( + "Git history contains an unmaterialized submodule transition " + f"that cannot be inspected safely: {name}" + ) + continue + if not isinstance(nested_baseline, dict): + raise GitInspectionError("The approved Git submodule baseline is invalid.") + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + raise GitInspectionError( + f"Git submodule baseline is unavailable after approval: {name}" + ) + nested_head = _git_bytes(nested_root, "rev-parse", "--verify", "HEAD") + if ( + nested_head is None + or ( + current_gitlink != approved_gitlink + and nested_head.decode("ascii", errors="strict").strip() + != current_gitlink + ) + ): + raise GitInspectionError( + f"Git submodule materialized commit does not match parent HEAD: {name}" + ) + allowed_gitlinks[name] = current_gitlink + materialized_submodules[name] = (nested_root, nested_baseline) + + documents = _git_history_blob_documents( + root, + revision=f"{baseline_head}..{current_head_text}", + repository_prefix=context[1], + display_prefix=display_prefix, + budget=budget, + allowed_gitlinks=allowed_gitlinks, + ) + for name, (nested_root, nested_baseline) in sorted(materialized_submodules.items()): + documents.extend( + _git_history_source_documents( + nested_root, + baseline=nested_baseline, + display_prefix=f"{display_prefix}{name}/", + ancestors=ancestors | {identity}, + budget=budget, + ) + ) + return documents + + +def _git_full_history_source_documents( + root: Path, + *, + commit: str, + display_prefix: str, + ancestors: frozenset[str], + budget: _HistoryScanBudget, +) -> list[tuple[str, str, bytes]]: + """Scan a complete Git history when approval predates the first commit.""" + + identity = os.path.normcase(str(root.resolve())) + if identity in ancestors: + raise GitInspectionError("Git submodule cycle detected during history inspection.") + context = _validated_git_context(root, strict=True) + if context is None or _git_bytes(root, "cat-file", "-e", f"{commit}^{{commit}}") is None: + raise GitInspectionError("Required Git history commit is unavailable.") + documents = _git_history_blob_documents( + root, + revision=commit, + repository_prefix=context[1], + display_prefix=display_prefix, + budget=budget, + allowed_gitlinks=_gitlink_paths(root, treeish=commit), + ) + for name, nested_commit in sorted(_gitlink_paths(root, treeish=commit).items()): + nested_root = _materialized_submodule_root(root, name) + if nested_root is None: + raise GitInspectionError( + f"Git history submodule is not materialized: {display_prefix}{name}" + ) + nested_head = _git_bytes(nested_root, "rev-parse", "--verify", "HEAD") + if ( + nested_head is None + or nested_head.decode("ascii", errors="strict").strip() != nested_commit + ): + raise GitInspectionError( + f"Git history submodule index and materialized commit disagree: " + f"{display_prefix}{name}" + ) + documents.extend( + _git_full_history_source_documents( + nested_root, + commit=nested_commit, + display_prefix=f"{display_prefix}{name}/", + ancestors=ancestors | {identity}, + budget=budget, + ) + ) + return documents + + +def _git_history_blob_documents( + root: Path, + *, + revision: str, + repository_prefix: str, + display_prefix: str, + budget: _HistoryScanBudget, + allowed_gitlinks: dict[str, str], +) -> list[tuple[str, str, bytes]]: + """Read bounded, supported source blobs from a Git object revision set.""" + + object_paths = _git_history_object_paths( + root, + revision=revision, + repository_prefix=repository_prefix, + budget=budget, + allowed_gitlinks=allowed_gitlinks, + ) + if not object_paths: + return [] + object_ids = sorted(object_paths) + checked = _git_bytes( + root, + "cat-file", + "--batch-check=%(objectname) %(objecttype) %(objectsize)", + input_data=b"\n".join(object_ids) + b"\n", + ) + if checked is None: + raise GitInspectionError("Required Git history object inspection failed.") + records = checked.splitlines() + if len(records) != len(object_ids): + raise GitInspectionError("Git history object inspection returned incomplete records.") + + blobs: list[tuple[bytes, int, str]] = [] + for object_id, record in zip(object_ids, records, strict=True): + fields = record.split(b" ", 2) + if len(fields) != 3 or fields[0] != object_id: + raise GitInspectionError("Git history object inspection returned a malformed record.") + object_type = fields[1] + if object_type == b"missing": + raise GitInspectionError("A required Git history object is unavailable.") + if object_type != b"blob": + continue + try: + size = int(fields[2]) + except ValueError as error: + raise GitInspectionError( + "Git history object inspection returned an invalid size." + ) from error + if size < 0 or size > _HISTORY_SCAN_MAX_BYTES - budget.bytes: + raise GitInspectionError( + f"Git history blob byte limit exceeded ({_HISTORY_SCAN_MAX_BYTES})." + ) + budget.bytes += size + blobs.append((object_id, size, object_paths[object_id])) + if not blobs: + return [] + + content_output = _git_bytes( + root, + "cat-file", + "--batch", + input_data=b"\n".join(object_id for object_id, _, _ in blobs) + b"\n", + ) + if content_output is None: + raise GitInspectionError("Required Git history blob read failed.") + position = 0 + documents: list[tuple[str, str, bytes]] = [] + for object_id, size, name in blobs: + header_end = content_output.find(b"\n", position) + expected_header = object_id + b" blob " + str(size).encode("ascii") + if header_end < 0 or content_output[position:header_end] != expected_header: + raise GitInspectionError("Git history blob read returned a malformed record.") + position = header_end + 1 + content_end = position + size + content = content_output[position:content_end] + if len(content) != size or content_output[content_end:content_end + 1] != b"\n": + raise GitInspectionError("Git history blob read returned incomplete content.") + position = content_end + 1 + documents.append((f"{display_prefix}{name}", "history", content)) + if position != len(content_output): + raise GitInspectionError("Git history blob read returned unexpected trailing data.") + return documents + + +def _git_history_object_paths( + root: Path, + *, + revision: str, + repository_prefix: str, + budget: _HistoryScanBudget, + allowed_gitlinks: dict[str, str], +) -> dict[bytes, str]: + """Map every historical source-path transition to its new blob. + + ``rev-list --objects`` emits at most one arbitrary path hint for a blob. + That is unsafe when one blob is reachable at both a supported and an + unsupported path. Enumerating each reachable commit's raw tree transition + preserves all paths, including merged side-branch commits. + """ + + object_paths: dict[bytes, str] = {} + for commit in _git_history_commit_ids(root, revision=revision, budget=budget): + _git_history_commit_object_paths( + root, + commit=commit, + repository_prefix=repository_prefix, + object_paths=object_paths, + budget=budget, + allowed_gitlinks=allowed_gitlinks, + ) + return object_paths + + +def _git_history_commit_ids( + root: Path, + *, + revision: str, + budget: _HistoryScanBudget, +) -> list[str]: + commits: list[str] = [] + + def collect(record: bytes) -> None: + if _GIT_OBJECT_ID.fullmatch(record) is None: + raise GitInspectionError( + "Git history enumeration returned a malformed commit ID." + ) + if len(commits) >= _HISTORY_SCAN_MAX_COMMITS: + raise GitInspectionError( + f"Git history commit limit exceeded ({_HISTORY_SCAN_MAX_COMMITS})." + ) + _consume_history_object_budget(budget) + commits.append(record.decode("ascii")) + + _stream_git_line_records( + root, + ("rev-list", "--full-history", revision, "--", "."), + collect, + operation="history enumeration", + ) + return commits + + +def _git_history_commit_object_paths( + root: Path, + *, + commit: str, + repository_prefix: str, + object_paths: dict[bytes, str], + budget: _HistoryScanBudget, + allowed_gitlinks: dict[str, str], +) -> None: + pending_object: bytes | None = None + pending_gitlink = False + + def collect(record: bytes) -> None: + nonlocal pending_gitlink, pending_object + if pending_object is None: + fields = record.split() + if ( + len(fields) != 5 + or not fields[0].startswith(b":") + or _GIT_OBJECT_ID.fullmatch(fields[3]) is None + or fields[4] not in {b"A", b"M", b"T"} + ): + raise GitInspectionError( + "Git history enumeration returned a malformed tree transition." + ) + _consume_history_object_budget(budget) + pending_object = fields[3] + pending_gitlink = fields[1] == b"160000" + return + if pending_gitlink: + name = _git_history_relative_name( + record, + repository_prefix=repository_prefix, + ) + expected_gitlink = allowed_gitlinks.get(name) if name is not None else None + if expected_gitlink != pending_object.decode("ascii"): + raise GitInspectionError( + "Git history contains a submodule transition that cannot " + "be inspected safely." + ) + pending_gitlink = False + pending_object = None + return + name = _git_history_source_name( + record, + repository_prefix=repository_prefix, + ) + if name is not None: + existing_name = object_paths.get(pending_object) + if existing_name is None or name < existing_name: + object_paths[pending_object] = name + pending_object = None + + _stream_git_nul_records( + root, + ( + "diff-tree", + "--root", + "-m", + "-r", + "--no-commit-id", + "--no-renames", + "--diff-filter=AMT", + "--raw", + "-z", + commit, + "--", + ".", + ), + collect, + operation="history tree enumeration", + ) + if pending_object is not None or pending_gitlink: + raise GitInspectionError( + "Git history enumeration returned an incomplete tree transition." + ) + + +def _consume_history_object_budget(budget: _HistoryScanBudget) -> None: + budget.objects += 1 + if budget.objects > _HISTORY_SCAN_MAX_OBJECTS: + raise GitInspectionError( + f"Git history object limit exceeded ({_HISTORY_SCAN_MAX_OBJECTS})." + ) + + +def _stream_git_line_records( + root: Path, + arguments: tuple[str, ...], + on_record: Any, + *, + operation: str, +) -> None: + """Call Git and feed newline-delimited records without unbounded buffering.""" + + _stream_git_records( + root, + arguments, + on_record, + delimiter=b"\n", + operation=operation, + ) + + +def _stream_git_nul_records( + root: Path, + arguments: tuple[str, ...], + on_record: Any, + *, + operation: str, +) -> None: + """Call Git and feed NUL-delimited records without unbounded buffering.""" + + _stream_git_records( + root, + arguments, + on_record, + delimiter=b"\0", + operation=operation, + ) + + +def _stream_git_records( + root: Path, + arguments: tuple[str, ...], + on_record: Any, + *, + delimiter: bytes, + operation: str, +) -> None: + """Call Git and feed delimited records without unbounded buffering.""" + + try: + process = subprocess.Popen( + ["git", "--no-replace-objects", *arguments], + cwd=root, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=_git_environment(), + ) + except OSError as error: + raise GitInspectionError(f"Required Git {operation} failed.") from error + stream = process.stdout + if stream is None: + process.terminate() + process.wait() + raise GitInspectionError(f"Required Git {operation} has no output stream.") + buffered = b"" + try: + while chunk := stream.read(64 * 1024): + buffered += chunk + if len(buffered) > 1024 * 1024: + raise GitInspectionError( + f"Git {operation} returned an oversized record." + ) + records = buffered.split(delimiter) + buffered = records.pop() + for record in records: + on_record(record) + if buffered: + raise GitInspectionError( + f"Git {operation} returned an unterminated record." + ) + if process.wait() != 0: + raise GitInspectionError(f"Required Git {operation} failed.") + except OSError as error: + raise GitInspectionError(f"Required Git {operation} failed.") from error + finally: + stream.close() + if process.poll() is None: + process.terminate() + process.wait() + + +def _git_history_source_name( + encoded_name: bytes, + *, + repository_prefix: str, +) -> str | None: + name = _git_history_relative_name( + encoded_name, + repository_prefix=repository_prefix, + ) + if name is None: + return None + if name == ".anchor" or name.startswith(".anchor/"): + return None + if os.name == "nt" and "\\" in name: + raise GitInspectionError( + "Git history contains a path that cannot be inspected safely on Windows." + ) + return name if _is_supported_source_name(name) else None + + +def _git_history_relative_name( + encoded_name: bytes, + *, + repository_prefix: str, +) -> str | None: + """Return a validated Git path relative to the inspected project root.""" + + prefix = repository_prefix.encode("utf-8", errors="surrogateescape").strip(b"/") + if prefix: + expected_prefix = prefix + b"/" + if not encoded_name.startswith(expected_prefix): + return None + encoded_name = encoded_name[len(expected_prefix):] + name = encoded_name.decode("utf-8", errors="surrogateescape") + while name.startswith("./"): + name = name[2:] + if ( + not name + or name.startswith("/") + or ".." in name.split("/") + ): + raise GitInspectionError("Git history enumeration returned an unsafe path.") + return name + + def _git_source_documents( root: Path, *, diff --git a/tests/test_actual_diff_risk.py b/tests/test_actual_diff_risk.py index bc2bd9b..fc4a9cf 100644 --- a/tests/test_actual_diff_risk.py +++ b/tests/test_actual_diff_risk.py @@ -41,6 +41,13 @@ def _git_output(self, root: Path, *arguments: str) -> str: text=True, ).stdout.strip() + @staticmethod + def _same_path(candidate: object, expected: Path) -> bool: + try: + return os.path.samefile(os.fspath(candidate), os.fspath(expected)) + except (OSError, TypeError): + return False + def _init_git(self, root: Path) -> None: self._git(root, "init", "--quiet") self._git(root, "config", "user.name", "AnchorLoop Tests") @@ -339,6 +346,244 @@ def test_committed_secret_blocks_precommit(self) -> None: ) ) + def test_secret_commit_then_revert_still_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + self._commit_file( + root, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._commit_file(root, "src/config.py", "API_KEY = None\n") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + def test_private_key_commit_then_delete_still_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + self._commit_file( + root, + "src/config.py", + "-----BEGIN PRIVATE KEY-----\n" + "not-a-real-key\n" + "-----END PRIVATE KEY-----\n", + ) + self._git(root, "rm", "--quiet", "--", "src/config.py") + self._git(root, "commit", "--quiet", "-m", "remove private key") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + def test_secret_inside_merged_commit_still_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + self._git(root, "checkout", "--quiet", "-b", "secret-history") + self._commit_file( + root, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._git(root, "checkout", "--quiet", "-") + self._git( + root, + "merge", + "--quiet", + "--no-ff", + "secret-history", + "-m", + "merge secret history", + ) + self._commit_file(root, "src/config.py", "API_KEY = None\n") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + def test_duplicate_history_blob_is_scanned_once(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + secret = 'API_KEY = "abcdefgh-secret-value"\n' + self._commit_file(root, "src/first.py", secret) + self._commit_file(root, "src/second.py", secret) + self._git(root, "rm", "--quiet", "--", "src/first.py", "src/second.py") + self._git(root, "commit", "--quiet", "-m", "remove duplicate secrets") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + history_findings = [ + finding + for finding in task["quality"][-1]["findings"] + if finding["category"] == "secret" + and "[history]" in finding["location"] + ] + self.assertEqual(len(history_findings), 1) + + def test_history_blob_limits_fail_closed(self) -> None: + cases = ( + ( + "_HISTORY_SCAN_MAX_COMMITS", + 0, + "history commit limit exceeded", + ), + ( + "_HISTORY_SCAN_MAX_OBJECTS", + 1, + "history object limit exceeded", + ), + ( + "_HISTORY_SCAN_MAX_BYTES", + 1, + "history blob byte limit exceeded", + ), + ) + for setting, limit, message in cases: + with self.subTest(setting=setting), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + self._commit_file(root, "src/new.py", "VALUE = 1\n") + project.transition("review") + + with ( + mock.patch(f"anchorloop.quality.{setting}", limit), + self.assertRaisesRegex(AnchorError, message), + ): + project.precommit() + + def test_nested_project_history_secret_uses_project_relative_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + self._init_git(repository) + root = repository / "nested-project" + self._commit_file(repository, "nested-project/README.md") + project = self._implementing_project(root, active_security=True) + self._commit_file( + repository, + "nested-project/src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._commit_file( + repository, + "nested-project/src/config.py", + "API_KEY = None\n", + ) + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + history_locations = [ + finding["location"] + for finding in task["quality"][-1]["findings"] + if finding["category"] == "secret" + and "[history]" in finding["location"] + ] + self.assertEqual(history_locations, ["src/config.py [history]:1"]) + + def test_nested_project_history_scans_a_blob_aliased_outside_project(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + self._init_git(repository) + root = repository / "nested-project" + self._commit_file(repository, "nested-project/README.md") + project = self._implementing_project(root, active_security=True) + secret = 'API_KEY = "abcdefgh-secret-value"\n' + self._commit_file(repository, "aaa/duplicate.py", secret) + self._commit_file(repository, "nested-project/src/config.py", secret) + self._commit_file( + repository, + "nested-project/src/config.py", + "API_KEY = None\n", + ) + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + def test_nested_history_budget_ignores_unrelated_commits(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + self._init_git(repository) + root = repository / "nested-project" + self._commit_file(repository, "nested-project/README.md") + project = self._implementing_project(root, active_security=True) + for index in range(3): + self._commit_file( + repository, + f"outside/change-{index}.py", + f"VALUE = {index}\n", + ) + self._commit_file( + repository, + "nested-project/src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._commit_file( + repository, + "nested-project/src/config.py", + "API_KEY = None\n", + ) + project.transition("review") + + with ( + mock.patch("anchorloop.quality._HISTORY_SCAN_MAX_OBJECTS", 4), + self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"), + ): + project.precommit() + + def test_history_scans_a_blob_aliased_to_an_unsupported_root_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + secret = 'API_KEY = "abcdefgh-secret-value"\n' + self._commit_file(root, "asset.bin", secret) + self._commit_file(root, "src/config.py", secret) + self._commit_file(root, "src/config.py", "API_KEY = None\n") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + + @unittest.skipIf(os.name == "nt", "Windows cannot create literal backslash names") + def test_history_scans_literal_backslash_source_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + self._commit_file(root, "README.md") + project = self._implementing_project(root, active_security=True) + relative = ".anchor\\config.py" + self._commit_file( + root, + relative, + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._git(root, "rm", "--quiet", "--", relative) + self._git(root, "commit", "--quiet", "-m", "remove secret") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + def test_staged_secret_cannot_be_masked_by_safe_unstaged_content(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -382,6 +627,23 @@ def test_unborn_git_head_secret_cannot_be_masked_by_baseline_worktree(self) -> N with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): project.precommit() + def test_unborn_git_secret_commit_then_revert_still_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._init_git(root) + config = root / "src" / "config.py" + config.parent.mkdir(parents=True) + config.write_text("API_KEY = None\n", encoding="utf-8") + project = self._implementing_project(root, active_security=True) + config.write_text('API_KEY = "abcdefgh-secret-value"\n', encoding="utf-8") + self._git(root, "add", "--", "src/config.py") + self._git(root, "commit", "--quiet", "-m", "commit secret") + self._commit_file(root, "src/config.py", "API_KEY = None\n") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + def test_git_initialized_after_non_git_approval_fails_closed(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -523,6 +785,312 @@ def test_unborn_git_first_commit_recursively_scans_submodule_tree(self) -> None: ): project.transition("review") + def test_unborn_git_submodule_secret_commit_then_revert_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + project = self._implementing_project(root, active_security=True) + + library = root / "vendor" / "library" + self._git(library, "config", "user.name", "AnchorLoop Tests") + self._git( + library, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + self._commit_file( + library, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._commit_file(library, "src/config.py", "API_KEY = None\n") + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + library_head = self._git_output(library, "rev-parse", "HEAD") + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + library_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "add library") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "Pre-commit is blocked"): + project.precommit() + task = json.loads(project.active_task_path.read_text(encoding="utf-8")) + self.assertTrue( + any( + finding["location"] + == "vendor/library/src/config.py [history]:1" + for finding in task["quality"][-1]["findings"] + ) + ) + + def test_unborn_git_safe_submodule_history_passes_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + project = self._implementing_project(root, active_security=True) + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + library_head = self._git_output( + root / "vendor" / "library", + "rev-parse", + "HEAD", + ) + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + library_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "add library") + project.transition("review") + + checked = project.precommit() + + self.assertEqual(checked["state"], "quality_checked") + + def test_intermediate_submodule_gitlink_transition_blocks_precommit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + source_head = self._git_output(source, "rev-parse", "HEAD") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + source_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "add library") + library = root / "vendor" / "library" + self._git(library, "config", "user.name", "AnchorLoop Tests") + self._git( + library, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + project = self._implementing_project(root, active_security=True) + + self._commit_file( + library, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._git(root, "add", "--", "vendor/library") + self._git(root, "commit", "--quiet", "-m", "point at secret") + self._git(library, "checkout", "--quiet", source_head) + self._git(root, "add", "--", "vendor/library") + self._git(root, "commit", "--quiet", "-m", "restore library baseline") + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "submodule transition"): + project.precommit() + + def test_unmaterialized_submodule_history_transition_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + approved_head = self._git_output(source, "rev-parse", "HEAD") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + approved_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "track unmaterialized library") + project = self._implementing_project(root, active_security=True) + + self._commit_file( + source, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + safe_head = self._git_output(source, "rev-parse", "HEAD") + self._git( + root, + "update-index", + "--cacheinfo", + "160000", + safe_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "advance unmaterialized library") + self._git( + root, + "update-index", + "--cacheinfo", + "160000", + approved_head, + "vendor/library", + ) + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "submodule transition"): + project.precommit() + + def test_materialized_submodule_history_must_match_parent_head(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source = base / "source" + source.mkdir() + self._init_git(source) + self._commit_file(source, "src/config.py", "API_KEY = None\n") + approved_head = self._git_output(source, "rev-parse", "HEAD") + + root = base / "project" + root.mkdir() + self._init_git(root) + self._commit_file(root, "README.md") + self._git( + root, + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + str(source), + "vendor/library", + ) + (root / ".gitmodules").write_text( + "[submodule \"vendor/library\"]\n" + "\tpath = vendor/library\n" + f"\turl = {source.as_posix()}\n", + encoding="utf-8", + ) + self._git(root, "add", "--", ".gitmodules") + self._git( + root, + "update-index", + "--add", + "--cacheinfo", + "160000", + approved_head, + "vendor/library", + ) + self._git(root, "commit", "--quiet", "-m", "track materialized library") + project = self._implementing_project(root, active_security=True) + + library = root / "vendor" / "library" + self._git(library, "config", "user.name", "AnchorLoop Tests") + self._git( + library, + "config", + "user.email", + "anchorloop-tests@example.invalid", + ) + self._commit_file( + library, + "src/config.py", + 'API_KEY = "abcdefgh-secret-value"\n', + ) + self._commit_file(library, "src/config.py", "API_KEY = None\n") + safe_head = self._git_output(library, "rev-parse", "HEAD") + self._git(root, "add", "--", "vendor/library") + self._git(root, "commit", "--quiet", "-m", "advance materialized library") + self._git(library, "checkout", "--quiet", approved_head) + self._git( + root, + "update-index", + "--cacheinfo", + "160000", + approved_head, + "vendor/library", + ) + project.transition("review") + + with self.assertRaisesRegex(AnchorError, "parent HEAD"): + project.precommit() + def test_quality_invalidates_masked_git_state_after_precommit(self) -> None: cases = ("staged-root", "committed-nested") for case in cases: @@ -717,11 +1285,8 @@ def test_filesystem_baseline_capture_fails_closed_on_read_error(self) -> None: self._plan(project, mode="STANDARD") real_open = os.open - risky_key = os.path.normcase(os.path.abspath(risky)) - def guarded_open(path: object, *args: object, **kwargs: object) -> int: - candidate = os.path.normcase(os.path.abspath(os.fspath(path))) - if candidate == risky_key: + if self._same_path(path, risky): raise PermissionError("simulated baseline read denial") return real_open(path, *args, **kwargs) # type: ignore[arg-type] @@ -752,11 +1317,8 @@ def test_filesystem_baseline_capture_fails_closed_on_lstat_error(self) -> None: self._plan(project, mode="STANDARD") real_lstat = os.lstat - risky_key = os.path.normcase(os.path.abspath(risky)) - def guarded_lstat(path: object, *args: object, **kwargs: object) -> object: - candidate = os.path.normcase(os.path.abspath(os.fspath(path))) - if candidate == risky_key: + if self._same_path(path, risky): raise PermissionError("simulated baseline lstat denial") return real_lstat(path, *args, **kwargs) # type: ignore[arg-type] @@ -790,11 +1352,8 @@ def test_filesystem_baseline_capture_fails_closed_on_scandir_error(self) -> None self._plan(project, mode="STANDARD") real_scandir = os.scandir - risky_key = os.path.normcase(os.path.abspath(risky_directory)) - def guarded_scandir(path: object) -> object: - candidate = os.path.normcase(os.path.abspath(os.fspath(path))) - if candidate == risky_key: + if self._same_path(path, risky_directory): raise PermissionError("simulated baseline directory denial") return real_scandir(path) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 19d9de9..d0d35a5 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -245,7 +245,7 @@ def test_release_documentation_stays_candidate_until_external_gates_complete(sel translations = sorted((root / "docs" / "i18n").glob("README.*.md")) self.assertIn("**Published production:** `anchorloop@0.1.0`", readme) - self.assertIn("**Unreleased main:** `0.2.0` release candidate", readme) + self.assertIn("**Unreleased candidate:** `0.2.0` release candidate", readme) self.assertNotIn("**Published production:** `anchorloop@0.2.0`", readme) self.assertIn('src="docs/assets/anchorloop-delivery-loop.svg"', readme) self.assertIn('src="docs/assets/anchorloop-evidence-integrity.svg"', readme)