From e7cadcf13be64ea0f727fd2018e0f61b9c33ff03 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Fri, 31 Jul 2026 00:40:26 -0400 Subject: [PATCH 01/66] docs(state): spec and plan the portable-release + protected-state sprint Records the design for #563 and #564 before autonomous execution begins, so an interrupted session is resumable rather than lost. Five adversarial passes changed the design rather than polishing it. The load-bearing corrections: the protected-state registry needs a per-entry policy, because marker-gating open-tasks.md would admit an agent composing board markdown under a marker while taskwrite.py is already invisible to every flank by construction; the archival sweep had no implementation path at all, since taskwrite carries no removal verb; and the plan's coverage proof passed twice over a criteria set with a hole in it, first missing the release-skill rewrite and then the consumer-portability proof the campaign exists to deliver. DECISION-0033 is superseded by DECISION-0034: declared pre-tag commands are check-only and may never mutate the tree. D-1 closes as fact, since taskwrite.py already resolved it; D-2 closes with /ca:standup owning the sweep at a 14-day cutoff. Refs: #563, #564, #565 --- .codearbiter/decisions/decision-log.md | 50 +++ .codearbiter/open-questions.md | 4 + .../portable-release-and-protected-state.md | 342 +++++++++++++++++ .../portable-release-and-protected-state.md | 228 ++++++++++++ .../specs/release-portable-fixture.md | 345 ++++++++++++++++++ .codearbiter/sprint-log.md | 7 + 6 files changed, 976 insertions(+) create mode 100644 .codearbiter/plans/portable-release-and-protected-state.md create mode 100644 .codearbiter/specs/portable-release-and-protected-state.md create mode 100644 .codearbiter/specs/release-portable-fixture.md diff --git a/.codearbiter/decisions/decision-log.md b/.codearbiter/decisions/decision-log.md index 5136a682..d5b42163 100644 --- a/.codearbiter/decisions/decision-log.md +++ b/.codearbiter/decisions/decision-log.md @@ -1114,3 +1114,53 @@ own sources so the report does not count its tests as covered source. Baseline recorded; shortfall tracked like #511. --- + +## DECISION-0033 — release-pre-tag-steps — Repo-specific pre-tag reconciliation is declared per target row, not pushed to CI + +**Date:** 2026-07-30 +**Status:** accepted +**Supersedes:** none +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** The `release` skill hardcodes this repo's pre-tag steps (README badge sync, command/skill/agent count derivation, `build-host-packages.py` regeneration) as skill prose. +- **Scaffold position:** A shippable skill cannot carry any repo's specific reconciliation commands; project state is where per-repo facts belong. +- **Status type:** open-decision-closure + +### Decision +Each release target row in `.codearbiter/release-targets.md` carries an ordered list of pre-tag shell commands. The skill runs them before tagging, asserts each exits 0, and asserts the tree is clean afterward. This repo's four existing steps move into its own rows unchanged. CI's `check_badge_consistency.py` is retained as the mechanical backstop it already is — the declared steps complement it rather than replace it. + +### SMARTS rationale +Testable, Available, Scalable, and Maintainable favored declared rows cleanly. Declared steps are data, so a fixture can assert run order and dirty-tree failure; pushing the steps to CI would require simulating CI status payloads to test, and would make tagging unavailable during any CI outage. Securable was the only non-Strong cell, and it falls inside the trust class ADR-0002 already accepted: operator-authored, PR-reviewed shell input, resolved by declaring the boundary rather than restricting the operator. The rejected local-hook-script option scored Weak on Securable for precisely the reason ADR-0002 rejected its own equivalent — an undeclared executable-input boundary. + +### Implementation implication +`.codearbiter/release-targets.md` gains a `pre-tag` list per row plus an assert-clean flag. `security-controls.md` gains a boundary-crossings entry declaring the file as executable input, with a length cap following ADR-0002's 1024-character precedent. An ADR is warranted for the new trust boundary and should be authored via `/ca:adr` during implementation. The `release` skill drops its four hardcoded reconciliation steps in favor of running the declared list. Tracked under issue #563. + +--- + +## DECISION-0034 — release-pre-tag-semantics — Declared pre-tag commands are check-only and may never mutate the tree + +**Date:** 2026-07-30 +**Status:** accepted +**Supersedes:** DECISION-0033 +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** DECISION-0033 held that pre-tag steps are declared per target row and run with exit-code and clean-tree assertions, and its implementation note described a per-row assert-clean flag. +- **Scaffold position:** This repo's actual pre-tag steps are edits, not assertions — `build-host-packages.py` regenerates the root manifest and `wc -l` count derivation implies editing README prose — so a clean-tree assertion blocks the very reconciliation the steps exist to perform. +- **Status type:** open-decision-closure + +### Decision +Pre-tag commands remain declared per target row rather than pushed to CI, restating DECISION-0033's holding in full. They are additionally constrained to be **check-only**: a command must assert and exit non-zero on drift, and must not mutate the working tree. The clean-tree assertion applies unconditionally, so any mutation is detected and blocks the release. No per-row assert-clean flag exists. Reconciliation itself — regenerating a manifest, syncing README badges — stays a separate action the operator performs and commits through `commit-gate` before re-running the release. + +### SMARTS rationale +Five of six lenses favored check-only. Maintainable and Testable: one rule with no branch, and a fixture asserts exit code plus unchanged tree without standing up commit-gate, whose own gates would otherwise leak into release tests. Reliable: the tree the suite ran against is the tree that gets tagged, with no mid-phase mutation landing after the last green run. Securable: because the clean-tree assertion always runs, a rogue declared command's writes surface before tagging, which the flagged and unconditional-mutation alternatives both lose. Available was the single Weak cell and is an accepted cost — a lagging generated manifest stops the release and names what to run. ADR-0008's ride-along precedent genuinely favored in-lane reconciliation, but it applies to narrow classified edits with named exemptions (`classify_board_diff` transitions, provenance re-baselines), never to arbitrary operator-declared commands; extending it here would let a regenerated manifest reach a tag without passing commit-gate review. + +### Implementation implication +`.codearbiter/release-targets.md` rows carry a `pre-tag` list with no assert-clean flag. The release skill runs each command, asserts exit 0, then asserts a clean tree, and BLOCKs on either failure. This repo's badge and count reconciliation must be expressed as check scripts — `check_badge_consistency.py` already has that shape; the catalog and README-table assertions need equivalent non-mutating checks written. `build-host-packages.py` is not a pre-tag command; a companion check asserts the generated root manifest matches the plugin manifest and fails when it lags. Costs this repo one extra loop per release when a generated artifact is stale. Tracked under issue #563, spec `specs/release-portable-fixture.md`. + +--- diff --git a/.codearbiter/open-questions.md b/.codearbiter/open-questions.md index a0fb4163..f41136dd 100644 --- a/.codearbiter/open-questions.md +++ b/.codearbiter/open-questions.md @@ -30,6 +30,10 @@ strategy (a), a lightweight staleness *warn* paired with the #186 gate-events si - **D-2 — Archival sweep owner + cutoff.** Which command runs the deliberate, confirmed, append-only sweep of long-settled `[x]` items from `open-tasks.md` into `done-tasks.md`, and what "long-settled" means (e.g. done >14 days). Archival is post-MVP; done items stay in-place under `## Done` until then. - **D-3 — Stale-in-progress threshold.** The age at which a `[~]` task triggers the SessionStart nudge. Default 3 days ships as a named constant; this only tunes the number (the mechanism is tested with an injected date, so the value is non-load-bearing). +### `release-portable-fixture` feature deferrals (spec: `specs/release-portable-fixture.md`, 2026-07-30) + +- **D-6 — `published-tags.json` location.** The tag-provenance manifest currently lives at `.github/published-tags.json`, a path the portable skill cannot assume exists in a consumer repo. _Narrowed 2026-07-30 after adversarial review:_ the spec's rev-1 form of this deferral was incoherent, because it waved the path off as "declared per target" while the row schema had no field to declare it in — leaving the skill's tag-provenance hard rule requiring a `.github/` path that the portability guard forbids. The schema now carries an optional `provenance-manifest` field (spec AC-3.5), and a row omitting it skips the recording step with an explicit note in the report. What remains deferred is only whether **this repo's** copy physically moves from `.github/` to `.codearbiter/` for consistency with the project-state store. Relocating requires repointing `check_tag_immutability.py` and its `[CHECK] | [REPO] | Published tag immutability` CI check in the same PR, since that check compares live refs against recorded entries and a path change with a stale checker reads as a deletion. Non-blocking. + ### `task-writer-harvest` feature deferrals (spec: `specs/task-writer-harvest.md`, 2026-06-21) - **D-4 — `/ca:standup` harvest backstop.** Whether the daily standup should offer to promote any workflow residue NOT caught at a terminal step (a sweep that re-scans recent checkpoint docs / sprint-log / plan files for un-promoted `[NEEDS-TRIAGE]`/DEFERRABLE/low-confidence items). v1 harvests only at the terminal steps; this is the safety net if a terminal step is skipped or interrupted. diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md new file mode 100644 index 00000000..b6d21057 --- /dev/null +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -0,0 +1,342 @@ +# Plan — portable release + protected-state machinery + +**Spec:** `.codearbiter/specs/portable-release-and-protected-state.md` +**Companion spec:** `.codearbiter/specs/release-portable-fixture.md` (rev 4) +**Date:** 2026-07-31 +**Status column is the resume ledger** — `subagent-driven-development` flips a task to `ACCEPTED` on +acceptance; an interrupted run re-enters at the first non-`ACCEPTED` task. + +> **Verification note.** Every `unittest discover` verification MUST be run with `NO_COLOR` unset. +> This harness exports `NO_COLOR=1`, which makes `statusline.py` strip SGR and fails 7 palette tests +> against a healthy tree. Prefix with `Remove-Item Env:\NO_COLOR -ErrorAction SilentlyContinue;` in +> the same shell call. + +## AC ledger + +### Workstream A — release portability + +The companion spec is **authoritative**; criteria are cited by ID with a short label rather than +duplicated, so the two documents cannot drift. + +| AC | label | +|---|---| +| A-1.1 | `core/pysrc/_releaselib.py` exists; `sync-core --check` passes | +| A-1.2 | mechanism carries no repo-namespace literal (denylist) | +| A-1.3 | repo defaults become required params (`classify_merge_readiness`, `last_tag_select`, `select_release_target`) | +| A-1.4 | `load_targets(path)` returns the full row schema | +| A-1.5 | absent block → declared error | +| A-1.6 | each parser-contract violation → its own declared error (8 cases) | +| A-1.7 | empty block → declared error | +| A-1.8 | series isolation against loaded data | +| A-1.9 | *(transitional)* shim re-exports mechanism, still exposes `RELEASE_TAG_PREFIXES` | +| A-1.10 | this repo's four rows load; target+prefix equal pre-change constants | +| A-1.11 | resolution trace reproduces a recorded pre-change run for `ca` and `ca-pi` | +| A-2.1 | pre-tag commands execute in declared order | +| A-2.2 | non-zero exit blocks | +| A-2.3 | dirty tree blocks; assertion precedes `rebuild` | +| A-2.4 | >1024-char `pre-tag` entry rejected | +| A-2.5 | `security-controls.md` boundary-crossings entry | +| A-2.6 | protected class admits `release-targets.md` writes only under marker | +| A-2.7 | four-case flank test | +| A-2.8 | `check_command_catalog.py` exists and is non-mutating | +| A-2.9 | this repo's declared rows run green on a reconciled tree | +| A-2.10 | `pre-tag` content-hash change forces re-confirmation | +| A-3.1 | declared manifest → assert equality, BLOCK on mismatch | +| A-3.2 | no manifest → tag is version source | +| A-3.3 | `rebuild` runs; artifacts asserted clean; nondeterministic bundler named | +| A-3.4 | `payload-exclude` honored (ca-pi `tools/`) | +| A-3.5 | `provenance-manifest` optional; absent → skip + report | +| A-3.6 | interpreter fallback where `python3` absent | +| A-4.1 | `payload_version_gate.py` derives prefixes from declared file | +| A-4.2 | target selection name-keyed | +| A-4.3 | workflow-contract test on name agreement | +| A-4.4 | constants removed from shim; six sites + gate still pass; A-1.9 test retired | +| A-5.1 | `decompose` elicits intent only | +| A-5.2 | `context-creation` writes a file `load_targets` accepts | +| A-5.3 | back-fill presents and requires confirmation | +| A-5.4 | back-fill persists; second run reads | +| A-5.5 | first-release changelog baseline instead of per-commit BLOCK | +| A-5.6 | provenance triggers are the rows' referenced paths | +| A-6.1 | reference-form guard over `core/surface/skills/**` | +| A-6.2 | `subagent-driven-development` farm.js reference resolves | +| A-6.3 | `decision-lifecycle` reworded to conditional CI reference | +| A-6.4 | `commands/release.md` matches the skill | +| A-6.5 | docs-site guide distinguishes general lane from this repo | + +### Workstream B — protected-state machinery + +| AC | criterion | +|---|---| +| B-01 | The registry carries a per-entry policy enum `{marker-gated, helper-only, append-only}`, present from slice 1 | +| B-02 | `marker-gated`: a Write is admitted only under a fresh authoring marker | +| B-03 | `marker-gated`: the Edit flank blocks via `classify_protected` per-class dispatch | +| B-04 | `marker-gated`: the shell flank blocks via a redirect + write-verb regex pair | +| B-05 | `helper-only`: Write, Edit, and shell naming the file are hard-blocked with **no** marker path | +| B-06 | `append-only`: mutation is admitted only via the helper's append verb | +| B-07 | `git add open-tasks.md` passes the shell flank | +| B-08 | `taskwrite add -- "fix open-tasks.md schema"` passes (filename as argv data) | +| B-09 | `tee open-tasks.md` and `>> open-tasks.md` block | +| B-10 | A stale marker (older than the freshness window) does not admit a write | +| B-11 | The class carries a stable `H-NN` ID cited in code comments and its test | +| B-12 | With enrolment live, a `taskwrite.py` invocation still succeeds (circularity proof) | +| B-13 | `release-targets.md` is registered `marker-gated` | +| B-14 | `open-tasks.md` is registered `helper-only` | +| B-15 | `done-tasks.md` is registered `append-only` | +| B-16 | `debug/SKILL.md:80` writes via `{{CMD:task}} add` rather than a direct append | +| B-17 | `taskwrite add` supports the rationale sub-bullet `debug` requires | +| B-18 | `context-creation`'s board population routes through the helper or a declared scaffold-time exemption | +| B-19 | A done-flip still classifies RETAINED through `classify_board_diff` after enrolment | +| B-20 | `taskwrite archive ` appends to `done-tasks.md` then removes from `open-tasks.md`, per item | +| B-21 | `archive` is rerun-safe: dedup by dotted ID, exact text for ID-less entries | +| B-22 | Interruption mid-sweep leaves item-level consistency — no duplicate, no loss | +| B-23 | `done-tasks.md` is created with the expected header shape | +| B-24 | `/ca:standup` offers the sweep under per-item confirmation | +| B-25 | Cutoff is a named constant, default done > 14 days, tested with an injected date | +| B-26 | An undated `[x]` archives only under explicit per-item confirmation | +| B-27 | An ADR records the executable-input boundary and names the ADR-0010 shell-indirection residual | + +## Tasks + +Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, shared kernel in +`core/pysrc/`, hook tests in `plugins/ca/hooks/tests/`, standalone gates in `.github/scripts/`. +`SUITE` = `Remove-Item Env:\NO_COLOR -EA SilentlyContinue; python -m unittest discover -s plugins/ca/hooks/tests -p "test_*.py"`. + +### Step 1 — B1: the class and registry (MVP slice begins) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-01 | `core/pysrc/_protectedstatelib.py` | `python -m py_compile` passes; module imports with zero side effects | registry module exists | B-01 | — | PENDING | +| T-02 | `core/pysrc/_protectedstatelib.py`, `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_policy_enum` — all three policies present, unknown policy raises | policy enum | B-01 | T-01 | PENDING | +| T-03 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_registry_lookup` — registered path returns its policy, unregistered returns None | registry lookup | B-01 | T-02 | PENDING | +| T-04 | `core/pysrc/_hooklib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_gated_write` — fresh marker admits, absent marker blocks | marker-gated Write | B-02 | T-03 | PENDING | +| T-05 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_stale` — marker older than the window blocks | marker freshness | B-10 | T-04 | PENDING | +| T-06 | `core/pysrc/pre-write.py` | `python .github/scripts/test_hook_guards.py` — Write flank dispatches on policy | pre-write flank | B-02, B-05 | T-04 | PENDING | +| T-07 | `core/pysrc/pre-edit.py` | `python .github/scripts/test_hook_guards.py` — Edit flank blocks per class | pre-edit flank | B-03, B-05 | T-06 | PENDING | +| T-08 | `core/pysrc/_bashguardlib.py` | `python .github/scripts/test_hook_guards.py` — redirect + write-verb pair blocks | shell flank | B-04, B-05 | T-07 | PENDING | +| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_git_add` — `git add open-tasks.md` passes | git-verb non-regression | B-07 | T-08 | PENDING | +| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_filename_as_data` — helper call with filename in description passes | argv-data non-regression | B-08 | T-08 | PENDING | +| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_shell_writes_block` — `tee` and `>>` both block | shell-write blocking | B-09 | T-08 | PENDING | +| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_helper_path_survives` — `taskwrite.py` invocation admitted with enrolment live | circularity proof | B-12 | T-08 | PENDING | +| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_append_only_policy` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | PENDING | +| T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | PENDING | +| T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | PENDING | +| T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | PENDING | + +**HARD GATE at T-16** — `/ca:adr` requires user attribution. Halts and surfaces. + +### Step 2 — A slices 1–4 (MVP slice continues through T-33) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-17 | `core/pysrc/_releaselib.py` | `python tools/sync-core.py --check` passes | mechanism ships | A-1.1 | T-15 | PENDING | +| T-18 | `core/pysrc/_releaselib.py`, `.github/scripts/test_release_lib.py` | `python .github/scripts/test_release_lib.py -k denylist` — no repo literal | data-free mechanism | A-1.2 | T-17 | PENDING | +| T-19 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k required_params` — three functions reject missing args | required params | A-1.3 | T-18 | PENDING | +| T-20 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k load_targets` — full row schema from a fixture | loader | A-1.4 | T-19 | PENDING | +| T-21 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k absent_block` raises the declared error | absent-block error | A-1.5 | T-20 | PENDING | +| T-22 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k parser_contract` — 8 violations, 8 distinguishable errors | parser contract | A-1.6 | T-21 | PENDING | +| T-23 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k empty_block` raises the declared error | empty-block error | A-1.7 | T-22 | PENDING | +| T-24 | `.github/scripts/test_release_lib.py` | `-k series_isolation` — `v` and `ca-pi-v` resolve independently | series isolation | A-1.8 | T-23 | PENDING | +| T-25 | `.github/scripts/_releaselib.py` | `python .github/scripts/payload_version_gate.py --plugin plugins/ca --base origin/main` exits 0 (bare invocation exits 2 — args are required) | transitional shim | A-1.9 | T-24 | PENDING | +| T-26 | `.codearbiter/release-targets.md` | `python .github/scripts/test_release_lib.py -k this_repo_rows` — 4 rows load, prefixes match constants, **and every one of the four declares `provenance-manifest`** | repo rows declared | A-1.10 | T-25 | PENDING | +| T-27a | `.github/scripts/fixtures/release-trace/` | `python .github/scripts/test_release_trace.py -k fixture_shape` — frozen tag list, manifests, commit graph, 4 rows | trace fixture | A-1.11 | T-26 | PENDING | +| T-27b | `.github/scripts/test_release_trace.py` | `-k old_lane_loads` — helpers pinned via `git show :.github/scripts/_releaselib.py` | pinned old lane | A-1.11 | T-27a | PENDING | +| T-27c | `.github/scripts/test_release_trace.py` | `-k old_lane_live` — the transcribed old lane resolves `ca`'s real last tag against the live repo; **divergence is a STOP, not a fixup** | old-lane validation | A-1.11 | T-27b | PENDING | +| T-27d | `.github/scripts/test_release_trace.py` | `-k trace_matches` — new lane reproduces the recorded variable dict for `ca` **and** `ca-pi` | trace assertion | A-1.11 | T-27c | PENDING | +| T-28 | `core/surface/skills/release/SKILL.md` | `test_release_lib.py -k pre_tag_order` — declared order preserved | pre-tag order | A-2.1 | T-27 | PENDING | +| T-29 | `core/pysrc/_releaselib.py` | `-k pre_tag_exit` — non-zero exit blocks | pre-tag exit | A-2.2 | T-28 | PENDING | +| T-30 | `core/pysrc/_releaselib.py` | `-k pre_tag_dirty` — dirty tree blocks, assertion precedes rebuild | clean-tree gate | A-2.3 | T-29 | PENDING | +| T-31 | `core/pysrc/_releaselib.py` | `-k pre_tag_cap` — >1024 chars rejected | length cap | A-2.4 | T-30 | PENDING | +| T-32 | `.codearbiter/security-controls.md` | boundary-crossings entry present; `test_release_lib.py -k boundary_entry` | boundary declared | A-2.5 | T-31 | PENDING | +| T-33 | `core/pysrc/_protectedstatelib.py`, `.codearbiter/release-targets.md` | `SUITE -k test_release_targets_registered` — marker-gated, 4-case flank test passes | consumer 1 enrolled | A-2.6, A-2.7, B-13 | T-32 | PENDING | + +**HARD GATE at T-32** — `security-controls.md` is a trust-boundary change. + +**— END MVP SLICE —** At T-33 the registry exists with a live consumer, the mechanism ships, this +repo's rows load, and the release lane is proven behavior-identical. Shippable on its own. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-34 | `.github/scripts/check_command_catalog.py` | script exits 0 on a reconciled tree, 1 on drift, mutates nothing | catalog check | A-2.8 | T-33 | PENDING | +| T-35 | `.codearbiter/release-targets.md` | all declared `pre-tag` commands exit 0 on a clean tree | rows run green | A-2.9 | T-34 | PENDING | +| T-36 | `core/pysrc/releasehash.py`, `tools/sync-core.py` generated set | `python tools/sync-core.py --check` passes with it enrolled; `SUITE -k test_pre_tag_hash` — changed hash forces re-confirmation | hash re-confirm | A-2.10 | T-35 | PENDING | +| T-37 | `core/pysrc/_releaselib.py` | `-k manifest_declared` — equality asserted, mismatch BLOCKs | manifest assert | A-3.1 | T-36 | PENDING | +| T-38 | `core/pysrc/_releaselib.py` | `-k manifest_absent` — tag is version source, no assertion | optional manifest | A-3.2 | T-37 | PENDING | +| T-39 | `core/pysrc/_releaselib.py` | `-k rebuild_artifacts` — stale bundle blocks, cause named | rebuild gate | A-3.3 | T-38 | PENDING | +| T-40 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k payload_exclude` — ca-pi `tools/` excluded | payload exclusions | A-3.4 | T-39 | PENDING | +| T-41 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k provenance_optional` — absent → skipped and reported | optional provenance | A-3.5 | T-40 | PENDING | + +**The skill rewrite — the campaign's central deliverable.** Absent from the first plan draft; a review +found the bijection passed because A-6.1 mapped to the guard *script* rather than the cleanup it +enforces. `SKILL.md` here means `core/surface/skills/release/SKILL.md` (the source; three payloads +generate from it). + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-41a | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k skill_uses_loader` — Targets table replaced by `load_targets`; no hardcoded row survives | table → loader | A-6.0 | T-41 | PENDING | +| T-41b | `core/surface/skills/release/SKILL.md` | `python .github/scripts/check_skill_portability.py` — no `.github/scripts/` invocation remains; helpers resolve under `${CLAUDE_PLUGIN_ROOT}` | helper repointing | A-6.0 | T-41a | PENDING | +| T-41c | `core/surface/skills/release/SKILL.md` | `-k skill_provenance_field` — Phase 3 step 5 reads the row field; absent → documented skip | provenance step | A-6.0, A-3.5 | T-41b | PENDING | +| T-41d | `core/surface/skills/release/SKILL.md` | `-k skill_conditional_prose` — hosted-lane and immutability sections conditional on repo capability | prose conditionals | A-6.0 | T-41c | PENDING | +| T-41e | — (review only) | adversarial Opus agent reviews the rewritten skill; BLOCK-level findings fixed and re-reviewed before proceeding | mid-sprint review | A-6.0 | T-41d | PENDING | +| T-42 | `core/surface/skills/release/SKILL.md` | `-k interpreter_fallback` — uses the shipped `python3 "

" … \|\| python "

" …` pattern (`taskwrite.py:11`) | interpreter fallback | A-3.6 | T-41e | PENDING | +| T-43 | `.github/scripts/payload_version_gate.py` | `python .github/scripts/test_payload_version_gate.py -k no_prefix_literal` | CI reads declared source | A-4.1 | T-42 | PENDING | +| T-44a | `.github/scripts/_releaselib.py` | `python .github/scripts/test_release_lib.py -k select_target_name_keyed` — `name=value` argv pairs; unknown name fails closed | shim CLI shape | A-4.2 | T-43 | PENDING | +| T-44b | `.github/workflows/release.yml` | `python .github/scripts/test_release_workflow.py -k name_keyed` — inputs plumbed by name, order-independent | workflow plumbing | A-4.2 | T-44a | PENDING | +| T-45 | `.github/scripts/test_release_workflow.py` | `-k name_agreement` fails when declared set and workflow inputs disagree | contract test | A-4.3 | T-44b | PENDING | +| T-46 | `.github/scripts/_releaselib.py` | `python .github/scripts/test_payload_version_gate.py`; `python .github/scripts/test_release_workflow.py`; `python .github/scripts/test_release_lib.py` all green; A-1.9's `test_releaselib_shim_exports_constants` deleted in this commit | shim data removed | A-4.4 | T-45 | PENDING | + +### Step 3 — A slice 5: onboarding and back-fill + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-47 | `core/surface/skills/decompose/SKILL.md` | `python .github/scripts/test_board_sync.py -k decompose_intent_only` | intent-only elicitation | A-5.1 | T-46 | PENDING | +| T-48 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_release_lib.py -k context_creation_writes_loadable` — the written file parses via `load_targets` | full elicitation | A-5.2 | T-47 | PENDING | +| T-49 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_requires_confirmation` — no write without confirm | back-fill gate | A-5.3 | T-48 | PENDING | +| T-50 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_persists` — second run reads, does not re-detect | back-fill persist | A-5.4 | T-49 | PENDING | +| T-51 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k first_release_baseline` — baseline derived from `git log --diff-filter=A -- .codearbiter/CONTEXT.md`, with a user override offered in the prompt | adoption baseline | A-5.5 | T-50 | PENDING | +| T-52 | `.codearbiter/.provenance/release-targets.json` | `python .github/scripts/test_provenancelib.py -k release_targets_triggers` | drift triggers | A-5.6 | T-51 | PENDING | + +### Step 4 — B3: the two conversions + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-53 | `core/pysrc/taskwrite.py`, `core/pysrc/_taskboardlib.py` | `python .github/scripts/test_taskwriter.py -k add_rationale` — sub-bullet round-trips; `python tools/sync-core.py --check` passes | helper extension | B-17 | T-52 | PENDING | +| T-54 | `core/surface/skills/debug/SKILL.md` | `python .github/scripts/test_board_sync.py -k debug_uses_helper` — no direct append remains | debug converted | B-16 | T-53 | PENDING | +| T-55 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_board_sync.py -k context_creation_board_route` — seeds via a repeated `taskwrite add` loop; **no file-absent exemption predicate exists** | scaffold route | B-18 | T-54 | PENDING | +| T-56 | `.github/scripts/test_board_sync.py` | `-k done_flip_retained` — flip classifies RETAINED with enrolment **simulated in a fixture**; live post-enrolment coverage is T-67 | ADR-0008 composition | B-19 | T-55 | PENDING | + +### Step 5 — B4: archive verb, done-tasks, sweep + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-57 | `core/pysrc/_taskboardlib.py` | `test_taskwriter.py -k archive_transform` — pure text→text move | archive transform | B-20 | T-56 | PENDING | +| T-58 | `core/pysrc/taskwrite.py` | `python .github/scripts/test_taskwriter.py -k archive_verb` — appends then removes, per item; **creates `done-tasks.md` with the canonical header when absent** | archive verb | B-20, B-23 | T-57 | PENDING | +| T-59 | `.github/scripts/test_taskwriter.py` | `-k archive_rerun` — dotted-ID and exact-text dedup, no duplicate | rerun safety | B-21 | T-58 | PENDING | +| T-60 | `.github/scripts/test_taskwriter.py` | `-k archive_interrupted` — kill between phases leaves no dup and no loss | interruption safety | B-22 | T-59 | PENDING | +| T-61 | `core/pysrc/init-codearbiter.py` | `python .github/scripts/test_taskboardlib.py -k done_tasks_shape` — greenfield scaffold path; `python tools/sync-core.py --check` passes | done-tasks scaffolded | B-23 | T-60 | PENDING | +| T-62 | `core/pysrc/_taskboardlib.py` | `-k archive_cutoff` — named constant, injected date | cutoff constant | B-25 | T-61 | PENDING | +| T-63 | `core/pysrc/_taskboardlib.py` | `-k archive_undated` — undated `[x]` items appear in their own section, **excluded from cutoff math**, archivable only per-item | undated rule | B-26 | T-62 | PENDING | +| T-64 | `core/surface/commands/standup.md` | `python .github/scripts/test_ux_conversion.py -k standup_sweep` — per-item confirmation | standup owns sweep | B-24 | T-63 | PENDING | +| T-65 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_done_tasks_registered` — append-only, archive verb admitted | consumer 3 enrolled | B-15 | T-64 | PENDING | + +### Step 6 — B2: open-tasks enrolment (lands last, per sequencing) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-66 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_open_tasks_registered` — helper-only, no marker path | consumer 2 enrolled | B-14 | T-65 | PENDING | +| T-67 | full suite | `SUITE` green; `python .github/scripts/test_taskwriter.py`; `test_board_sync.py` | enrolment regression | B-05, B-12, B-19 | T-66 | PENDING | + +### Step 7 — A slice 6: surfaces + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-68a | `.github/scripts/check_skill_portability.py` | `python .github/scripts/test_skill_portability.py -k matching_rule` — reference-form rule stated in the docstring; flags an executed `.github/scripts/` path, does **not** flag a scan-target list entry | matching rule | A-6.1 | T-67 | PENDING | +| T-68b | `.github/scripts/check_skill_portability.py` | guard exits 1 against `core/surface/skills/**` at `469c2fb`, exits 0 after T-41a–d, T-69 and T-70 | guard wiring | A-6.1 | T-68a | PENDING | +| T-69 | `core/surface/skills/subagent-driven-development/SKILL.md` | guard passes; reference reads `${CLAUDE_PLUGIN_ROOT}/tools/farm.js` | farm.js reference | A-6.2 | T-68b | PENDING | +| T-70 | `core/surface/skills/decision-lifecycle/SKILL.md` | guard passes; line is a conditional CI reference | ADR-identity reference | A-6.3 | T-69 | PENDING | +| T-71 | `core/surface/commands/release.md` | `python .github/scripts/check-plugin-refs.py`; documents `[target]` only — `--auto`, `--dry-run` and `` deleted (a real dry-run is tracked as #565); phase numbers match the skill | command surface | A-6.4 | T-70 | PENDING | +| T-72 | `site/src/content/docs/guides/releasing-a-version.md` | `npm --prefix site test` green | docs guide | A-6.5 | T-71 | PENDING | + +### Step 8 — Completion proof (the sprint is not done without this) + +Per the maintainer's completion bar: proven to work **and** to port. Verifying against this repo's +hand-built `.codearbiter/` state is the documented way consumer-facing bugs stay hidden, so the +consumer proof runs in a scratch repo with no file from this repository present. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-73 | `.github/scripts/test_consumer_smoke.py` | scratch repo built: one `package.json`, one `CHANGELOG.md`, tag `v1.2.3`, codeArbiter installed, **zero files from this repo** | consumer fixture | A-6.6 | T-72 | PENDING | +| T-74 | `.github/scripts/test_consumer_smoke.py` | `-k backfill_detects` — no declared file → detected shape presented, refuses to proceed unconfirmed | consumer back-fill | A-6.6 | T-73 | PENDING | +| T-75 | `.github/scripts/test_consumer_smoke.py` | `-k consumer_end_to_end` — target resolution, window derivation, bump classification and changelog rolling all succeed with no non-payload path touched | portability proof | A-6.6 | T-74 | PENDING | +| T-76 | `.github/scripts/test_release_trace.py` | `-k this_repo_still_releases` — `/ca:release ca` composes a tag on a scratch branch at the version the pre-change lane would derive; tag discarded, never pushed | this repo still releases | A-6.7 | T-75 | PENDING | + +## Pre-run dispositions (maintainer-answered 2026-07-31) + +Encoded here so a subagent does not re-surface them. Full SMARTS in `sprint-log.md`. + +- **T-06 – T-08 — delegate with a tripwire.** Proceed when `security-reviewer` PASSes and B-07…B-12 + are green; halt only on a finding. This was a risk-appetite call, not an analysis result: Reliable + and Securable favored an unconditional halt. +- **T-16 / T-32 — content pre-approved.** ADR-**0024** (verified next-free) records the + executable-input boundary, the ≤1024 cap, that the marker is audit friction rather than + authorization, and names the ADR-0010 shell-indirection residual per flank. The + `security-controls.md` row mirrors ADR-0002's. T-32 lands without a stop; **T-16 still halts, for + attribution only**. +- **T-41e — adversarial Opus review**, not a maintainer stop. Maintainer reviews the skill text at PR + stage. +- **Identifiers** — hook ID **H-22** (H-21 is taken); ADR **0024**; marker `release-targets-authoring`, + with `-authoring` as the pattern for future consumers. +- **T-13 / T-65 — `append-only` blocks all tool writes.** Flank-identical to `helper-only`; the + distinction lives in the helper's verb constraint. No tail-anchored-Edit admission. +- **T-55 — helper loop, no exemption predicate.** A file-absent exemption would let delete-then-Write + launder arbitrary content through "the file is absent". + +**LOW — a known dead window.** Between T-33 (rows enrolled `marker-gated`) and T-49/T-50 (the minter +prose lands), no lane can legally edit `release-targets.md`. A correction in that window needs a +hand-armed marker or `/ca:override`. Expected, not a defect — do not treat the block as a failure. + +## Coverage proof + +**Every AC → at least one task.** A-1.1→T-17, A-1.2→T-18, A-1.3→T-19, A-1.4→T-20, A-1.5→T-21, +A-1.6→T-22, A-1.7→T-23, A-1.8→T-24, A-1.9→T-25/T-46, A-1.10→T-26, A-1.11→T-27a/b/c/d, A-2.1→T-28, +A-2.2→T-29, A-2.3→T-30, A-2.4→T-31, A-2.5→T-32, A-2.6→T-33, A-2.7→T-33, A-2.8→T-34, A-2.9→T-35, +A-2.10→T-36, A-3.1→T-37, A-3.2→T-38, A-3.3→T-39, A-3.4→T-40, A-3.5→T-41/T-41c, A-3.6→T-42, +A-4.1→T-43, A-4.2→T-44a/T-44b, A-4.3→T-45, A-4.4→T-46, A-5.1→T-47, A-5.2→T-48, A-5.3→T-49, +A-5.4→T-50, A-5.5→T-51, A-5.6→T-52, **A-6.0→T-41a/T-41b/T-41c/T-41d/T-41e**, A-6.1→T-68a/T-68b, +A-6.2→T-69, A-6.3→T-70, A-6.4→T-71, A-6.5→T-72, **A-6.6→T-73/T-74/T-75, A-6.7→T-76**. +B-01→T-01/02/03/15, B-02→T-04/T-06, B-03→T-07, B-04→T-08, B-05→T-06/07/08/T-67, B-06→T-13, +B-07→T-09, B-08→T-10, B-09→T-11, B-10→T-05, B-11→T-14, B-12→T-12/T-67, B-13→T-33, B-14→T-66, +B-15→T-65, B-16→T-54, B-17→T-53, B-18→T-55, B-19→T-56/T-67, B-20→T-57/T-58, B-21→T-59, B-22→T-60, +B-23→T-58/T-61, B-24→T-64, B-25→T-62, B-26→T-63, B-27→T-16. + +**Every task → at least one AC.** Verified across all 86 tasks; no task covers nothing. + +Bijective coverage proven: **72 criteria, 86 tasks**, no uncovered criterion and no orphan task. + +*Rev 2/3 note — two holes were in the ledger, not the task set.* The first draft claimed bijection +over 69 criteria and 72 tasks. The claim was formally true and hollow both times: A-6.1 mapped to the +portability guard *script* while nothing rewrote the skill it polices (closed by A-6.0 + T-41x), and +#563's consumer-portability acceptance existed only as a prose checkbox, so no criterion and +therefore no task covered the thing the campaign is *for* (closed by A-6.6/A-6.7 + T-73–T-76). A +coverage proof over a criteria set with a hole in it proves the hole is consistent, nothing more. + +## Dependency order + +Strictly linear as written, with no cycle: +B1 (T-01–16) → A 1–4 (T-17–T-46, including the T-41x skill rewrite) → A 5 (T-47–52) → +B3 (T-53–56) → B4 (T-57–65) → B2 enrolment (T-66–67) → A 6 (T-68a–72). + +Ordering constraints that are not merely sequential: + +- **T-46 must not land before T-43–T-45**, or `payload_version_gate.py` breaks on every PR. T-25's + transitional test is deleted in T-46's own commit. +- **T-41a–d must land before T-68b**, since the guard cannot go green while the release skill still + carries its non-payload references. T-68b's verification names them explicitly. +- **T-58 must land before T-65.** The archive verb creates `done-tasks.md` when absent; once the file + is enrolled `append-only`, no tool write can create it. Every already-initialized repo — including + this one — never re-runs `init-codearbiter.py`, so T-61's scaffold path alone would leave the file + missing and the first archive failing. + +## Hard gates on the critical path + +After the pre-run dispositions, **two** stops remain rather than four: + +- **T-16** — ADR-0024 attribution. Content pre-approved; the halt is the signing act only. +- **Landing** — merge to the default branch; `/ca:sprint` auto-selects open-PR and never merges. + +Downgraded, with the reason recorded: + +- **T-06 – T-08** — now conditional. Halts only if `security-reviewer` reports a finding or any of + B-07…B-12 is red. +- **T-32** — no longer a stop; the boundary row text is pre-approved. + +Conditional stops that are not scheduled but may fire on genuinely new evidence: + +- **T-27c** — if the transcribed old-lane script disagrees with the live repo, that is new evidence + about the pre-change lane, not a fixup. STOP and investigate. +- **T-35** — a stale badge is not a stop (DECISION-0034 pre-decides the reconcile-and-rerun loop), but + drift revealing the catalog itself is wrong is new information. + +## Out of scope + +- `[NEEDS-TRIAGE]` D-6: whether `.github/published-tags.json` relocates to `.codearbiter/`. Tracked + in `open-questions.md`; not planned here. diff --git a/.codearbiter/specs/portable-release-and-protected-state.md b/.codearbiter/specs/portable-release-and-protected-state.md new file mode 100644 index 00000000..f56fc94c --- /dev/null +++ b/.codearbiter/specs/portable-release-and-protected-state.md @@ -0,0 +1,228 @@ +# Sprint spec — portable release + protected-state machinery + +**Date:** 2026-07-31 +**Slug:** `portable-release-and-protected-state` +**Issues:** #563 (release portability, workstream A); #564 (protected-state machinery, workstream B) +**Companion spec:** `specs/release-portable-fixture.md` (rev 4, three adversarial passes, cleared) +**Decisions:** DECISION-0034; sprint-log D-1/D-2 closure 2026-07-31 + +## Goal + +Two workstreams that share one piece of machinery. `/ca:release` becomes a portable fixture whose +repo-specific facts live in project state — and the guard that makes those facts trustworthy is built +as **generic marker-gated protected-state infrastructure**, with the release file as one of three +consumers rather than its reason. + +## Standing steers (use these to break SMARTS ties) + +Stated by the user across the design session. A `tied` or `moderate` SMARTS call resolves toward +these before falling back to the §2 conflict hierarchy. + +1. **Context economy.** Minimize what is loaded into context on every turn while keeping it available + when relevant. This is why the release rows live outside `CONTEXT.md` and why JIT surfacing is + preferred over always-on inclusion. +2. **Helpers write; inference does not.** The project is moving toward project-state files mutated by + sanctioned helpers rather than by an agent composing markdown. The guard exists to make the helper + the only path. +3. **Generic over special-case.** The protected-state class is parameterized over a registry. A + one-off for a single file would have to be torn out at the first second consumer. +4. **Reliability and testability over convenience.** The observed pattern across prior decisions + (mutation-testing directive, dry-run-the-path, no-silent-reconcile) and the one applied in + DECISION-0034. +5. **No repo-local variant of a shipped skill**, and no behavior change to this repo's four-plugin + release. +6. **Never drop adversarial verification to save budget.** Sequence lanes instead; push WIP early so + interrupted work is recoverable. +7. **Determinism over suggestion — weight `Scalable` heavier than usual.** The project is moving work + out of prose "suggestions" that an agent may or may not honor, and into deterministic helpers and + hooks. Two reasons, both load-bearing: it cuts tokens, and it turns process rules into things that + are *enforced* rather than *advised*. When a SMARTS call is between adding skill prose and building + a helper or hook, `Scalable` carries extra weight and the deterministic option wins ties. + +## Review and completion standards + +- **Adversarial passes** run one pass with an **Opus** model at **medium** effort. (The Agent tool + pins the model but has no effort parameter; effort inherits the session setting.) +- **All HIGH findings must be remediated.** MEDIUM findings are fixed or filed at discretion. +- **The advisor is a maintainer proxy.** Questions that would otherwise stop the run for the user may + be put to the standing adversarial advisor and answered as if it were them — except the hard gates, + which remain true stops. +- **Completion bar: the replacement must be proven to work AND to port.** The sprint is not done when + the tasks are `ACCEPTED`; it is done when the new lane is demonstrated working in this repo and in a + clean consumer repo. Verifying against this repo's hand-built `.codearbiter/` state is exactly the + condition that hides consumer-facing bugs. + +## Workstream A — release portability + +Fully specified in `specs/release-portable-fixture.md` rev 4: 42 acceptance criteria across six +slices, cleared by three adversarial passes. Not restated here. Its dependency on this sprint is +narrow and explicit: **AC-2.6 and AC-2.7 are satisfied by workstream B's class**, with +`release-targets.md` registered as its first consumer. + +## Workstream B — generic protected-state machinery + +### B1 — The class + +A registry-based protected-write class over project-state files. **Each registry entry carries a +policy**, because the three consumers need materially different write semantics and a flat +marker-gated registry is wrong for two of them: + +| policy | semantics | consumer | +|---|---|---| +| `marker-gated` | Write/Edit/shell admitted only under a fresh authoring marker | `release-targets.md` | +| `helper-only` | Write/Edit/shell naming the file are **hard-blocked with no marker path**; the sanctioned helper's own file I/O is the only route | `open-tasks.md` | +| `append-only` | mutation only via the helper's append verb | `done-tasks.md` | + +**The policy enum is built in slice 1** even though only `marker-gated` is exercised then. Shipping a +marker-only schema would force a breaking rebuild at step 6. + +Why `open-tasks.md` must NOT be marker-gated: `taskwrite.py` already exists and its header states it +is "the ONLY blessed way to write `.codearbiter/open-tasks.md`". It is Python file I/O invoked via +Bash whose argv never lexically names the file, so it is **invisible to all three flanks by +construction**. A marker would therefore add nothing for the helper while *admitting* an agent that +composes board markdown under that marker — exactly what steer 2 forbids. The correct enrolment is a +hard block with no marker path at all. + +For the `marker-gated` policy only, the mechanism is the H-11 pattern +(`decision-lifecycle/SKILL.md:37,55` — mint immediately before the write, `rm -f` at lane exit; +`_hooklib.marker_fresh` is a 30-minute mtime window). + +**Registered on all three flanks**, using CONTEXT.md's existing guards as the template: + +- `pre-write.py` — the Write door +- `pre-edit.py` — the Edit door, via `classify_protected` per-class dispatch +- `_bashguardlib` — a redirect and write-verb regex pair mirroring `CONTEXT_REDIRECT_RE` / + `CONTEXT_WRITE_RE` (lines 355-356, checked at 1011) + +A one-flank implementation is a non-fix: it passes a Write-door test while +`echo '…' >> ` still lands. The shell-indirection residual (`f=…; sed -i "$f"`, novel +`python -c` spellings) is the same accepted ADR-0010 cooperative-attestation residual CONTEXT.md +already carries, and must be **named** in the new ADR rather than left implied. The marker's value is +audit friction, not authorization. + +**The registry is the deliverable, not the entries.** Adding a fourth protected file later must be a +registry entry with a policy, never a new hook branch. + +**Two flank regressions are B1 obligations, not enrolment-time discoveries.** `commit-gate` Phase 7 +runs `git add open-tasks.md` (`commit-gate/SKILL.md:113`) — a Bash argv naming a protected file — and +`/ca:task add -- "fix open-tasks.md schema"` puts the filename in argv as data. If the mirrored +verb-set drifts to include git verbs, **commit-gate blocks itself on every retained board flip**. +Pinned tests: `git add open-tasks.md` passes, a filename-in-description helper call passes, +`tee open-tasks.md` and `>> open-tasks.md` block. + +### B2 — Consumers + +| file | policy | write route | +|---|---|---| +| `release-targets.md` | `marker-gated` | `context-creation`, the back-fill lane, `/ca:release`'s row-edit path | +| `open-tasks.md` | `helper-only` | `taskwrite.py` exclusively; lands **last** per sequencing | +| `done-tasks.md` | `append-only` | the B4 archive verb exclusively | + +`done-tasks.md` is `helper-only`/`append-only` rather than joining the H-05 audit set. The audit set +would supply Write-block, tail-anchored append, and a shell flank for free, but it permits *any* +cooperative append — weaker than routing through the archive verb this sprint must build regardless. + +### B3 — Writer inventory (corrected; verified, not heuristic) + +The earlier "seven or eight writers" estimate was wrong in both directions. Verified inventory: + +**Already helper-routed — no conversion needed.** `harvest.md:51-52` promotes work via +`{{CMD:task}} add`, so every harvest-invoking surface (`commit-gate` Phases 7/151, `tdd`, +`brainstorming:75`, `writing-plans:102`) already writes through the helper. + +**Not writers at all.** `using-git-worktrees:34` explicitly says *not* the backlog; +`standup.md:44` states "the board is never mutated here" (it becomes a writer only via B4); +`reference-map.md` is routing. `commit-gate`'s apparent four paths overcount — line 97 is diff +retention and line 113 is `git add`. + +**Hook-layer writers the surface scan cannot see.** `init-codearbiter.py:96` *creates* +`open-tasks.md`, and `taskwrite.py` itself. Both are helper-path and flank-invisible. Verified +read-only: `boardsync.py` (its header says "Writes nothing"), `session-start.py:1123`, +`statusline.py`, `_arbiterstatelib.py`. + +**The only two surfaces needing real work:** + +1. `debug/SKILL.md:80` instructs a **direct append** carrying an indented `- Desc:` rationale + sub-bullet. `taskwrite add` has no sub-bullet support, so this is a conversion *plus a helper + extension*, not a prose swap. +2. `context-creation/SKILL.md:103` **populates** `open-tasks.md` via the Write tool during + doc-writing — blocked under `helper-only`. Needs either repeated `taskwrite add` seeding or a + scaffold-time exemption while the file is uninitialized. + +**ADR-0008 composes cleanly — verified by trace.** `/ca:task done X` → `taskwrite.py` stamps +`(done YYYY-MM-DD)` (`_taskboardlib.py:761`) → the Bash flank sees no lexical filename and passes → +`commit-gate` Phase 6 `classify_board_diff` sees a clean done-flip (the stamp is *required* by the +classifier at line 406/453) → RETAINED → Phase 7 stages by explicit path. The write-time guard and the +commit-time classifier act on different objects — a tool call versus a staged diff — so they are +complementary, not double-gating. + +**Circularity hazard — the structural mitigation, not the sequencing one.** Under `helper-only` the +guard *cannot* block `/ca:task`, because the lexical flanks never see the helper's write. That +construction, not "enrolment lands last," is what makes the hazard safe, and it gets a pinned test +(helper invocation succeeds with enrolment live). The residual is an overbroad flank regex blocking +unrelated Bash, where `/ca:override` is available and logged. + +### B4 — Archival sweep (closes D-2) + +**The sweep has no implementation path today and needs a new helper verb.** `taskwrite.py`'s verbs are +exactly `add`/`start`/`done` with no removal, and the routing table says "never delete to complete." +With `open-tasks.md` helper-only and `done-tasks.md` append-only, a model Edit performing the move is +blocked by this sprint's own guard. + +- **New verb `taskwrite archive [--date]`** (or `sweep --cutoff N`): **per-item** + append-to-`done-tasks` first, then remove from `open-tasks`, rerun-safe via dedup on dotted ID + (exact text for ID-less entries). Batch ordering is unsafe — appending all N then removing all N + duplicates every item if interrupted between phases, and the reverse order loses records. Per-item + ordering also maps standup's per-item confirmation 1:1 onto helper calls. +- **Owner: `/ca:standup`** — the daily-hygiene lane with per-action confirmation and a + never-destructive-without-a-yes contract, which is verbatim D-2's requirement. This holds + *conditional on the archive verb existing*; without it the composition genuinely fails, which is + why the arbitration was logged `confidence: low`. +- **Cutoff: done > 14 days**, a named constant per D-3's precedent, tested against an injected date. +- **Undated `[x]` items** archive only under explicit per-item confirmation. Both `taskwrite done` and + the ADR-0008 classifier enforce `(done …)` stamps, so an undated entry is legacy or override-era. + +**D-1 closes as fact, not decision** — verified: `taskwrite.py:2` literally says "resolves D-1". The +`open-questions.md` D-1 text predates the writer and is stale. What D-1 left open is that the +sanctioned path is not *enforced*, which is B1. + +## Sequencing + +1. **B1** — the class and registry **including the full policy enum**, with `release-targets.md` as + its only live consumer. The enum is designed now, from B3's verified inventory, even though only + `marker-gated` is exercised at this point; deferring it forces a breaking schema rebuild at step 6. + The two flank regressions are obligations here. +2. **A slices 1–4** — mechanism ships, data loads, pre-tag execution, CI repointed. +3. **A slice 5** — onboarding and back-fill. +4. **B3 conversions** — `debug` and `context-creation` onto the helper path, plus the `taskwrite add` + sub-bullet extension `debug` needs. The inventory itself is already verified, so this is bounded + conversion work rather than discovery. +5. **B4** — the `taskwrite archive` verb, `done-tasks.md`, and the standup sweep. Runs + sanctioned-but-unguarded in the gap before step 6, which is fine. +6. **B2 open-tasks enrolment** — last, once both conversions have landed. +7. **A slice 6** — surface reconciliation. The docs-site half (AC-6.5) is separable and carries its + own verification regime (`npm test` over the generator suites). + +## Expected hard gates + +Named in advance so their tripping is a planned stop rather than a signal the spec was thin: + +- **`security-controls.md` boundary entry** (A's AC-2.5) — a trust-boundary change, never auto-decided. +- **The protected-write class itself** — it modifies hook guards that run in every consumer repo. +- **The new ADR** for the executable-input boundary and the ADR-0010 residual — `/ca:adr` requires + user attribution by hard rule. +- **Merge to the default branch** — `/ca:sprint` auto-selects open-PR and never merges. + +Everything else is auto-decidable under SMARTS with the steers above. + +## Out of scope + +- Relocating `.github/published-tags.json` (D-6 stays deferred; the row field makes the lane coherent). +- Changing what any release gate *does*, beyond what rev 4 specifies. +- Enrolling project-state files beyond the three named consumers. The registry makes that a later + one-line change, which is the point. + +## Non-gating open question + +`[CONFIRM-05]` (the `--farm` Feature Forge promotion bar) is open but unrelated: this sprint runs the +premium backend and does not touch the farm seam. It is not resolved here and does not block. diff --git a/.codearbiter/specs/release-portable-fixture.md b/.codearbiter/specs/release-portable-fixture.md new file mode 100644 index 00000000..3b72168a --- /dev/null +++ b/.codearbiter/specs/release-portable-fixture.md @@ -0,0 +1,345 @@ +# Spec — `/ca:release` as a portable, shippable fixture + +**Date:** 2026-07-30 (rev 4, after three adversarial review passes — cleared for planning) +**Status:** awaiting approval +**Issue:** #563 +**Governs:** core/surface/skills/release/**, core/surface/commands/release.md, core/pysrc/_releaselib.py, plugins/ca/skills/release/**, .github/scripts/_releaselib.py, .codearbiter/release-targets.md + +## Problem + +`/ca:release` encodes this repository's release mechanics as skill logic and depends on three files +outside the plugin payload — `.github/scripts/_releaselib.py`, `tools/build-host-packages.py`, and +`.github/published-tags.json`. None ship under `plugins/ca/`, so the lane cannot execute in any repo +that installs codeArbiter. Pre-flight's first resolution step calls a helper that is not there. + +The skill was portable at `6a45173` (51 lines, `git describe --tags`, root `CHANGELOG.md`) and lost +portability at `c12b1a3` (#125), which is an ancestor of the multi-plugin work at `c20c2d0` (#497). +Every hardening commit fixed a real failure; the defect is that each fix was written as a hardcoded +fact about this repository instead of a parameter read from project state. + +**Measurement rule.** A reference is contaminating when it names a path **belonging to this +repository** that the skill *executes or reads*. Bare substring matching is rejected — +`context-creation/SKILL.md:49` has a scout read `.github/workflows/` in the **consumer's** repo, +which is correct and must never be flagged. Under this rule three shipped skills are contaminated: +`release` (heavily), `subagent-driven-development:45` (`tools/farm.js`, which ships at +`plugins/ca/tools/farm.js` and so resolves in neither form), and `decision-lifecycle:70`. + +## Scope + +**In scope.** Split `_releaselib` into portable mechanism (to `core/pysrc/`) and repo-specific data +(to project state); a new `.codearbiter/release-targets.md` with a defined grammar and parser +contract; check-only pre-tag commands per row (DECISION-0034); a new protected-write class for the +declaration file; `context-creation` full elicitation with `decompose` intent-only and a release-time +back-fill; CI repointed to the declared source with name-keyed target selection; all three +contaminated skills fixed; `commands/release.md` reconciled with the skill. + +**Explicitly out of scope.** Changing what the gates *do* — immutable-tag doctrine, the +`CHANGELOG:`-footer BLOCK, publish read-back, derive-don't-guess all survive unchanged in behavior. +Redesigning `_releaselib`'s algorithms. The `release.yml` job structure beyond name-keying selection. +Adding a governance host. Changing this repo's four target definitions. **Relocating** +`.github/published-tags.json` — its path becomes an optional row field; where this repo's copy lives +is deferred to D-6. + +## Source of truth + +Every skill and command edit lands in `core/surface/`, never a generated `plugins/*/` copy. The +release skill ships in three payloads (`plugins/ca/skills/release/`, +`plugins/ca-codex/skills/ca-release/`, `plugins/ca-pi/skills/ca-release/` plus +`plugins/ca-pi/routines/release/`). Guards and structural assertions target the surface source. + +## Migration ordering (load-bearing) + +Six sites shell out to `python3 .github/scripts/_releaselib.py` — `release.yml:135,171` and +`.github/actions/publish-release/action.yml:125,164,180,228` — and `payload_version_gate.py:53` +imports `RELEASE_TAG_PREFIXES` from it at module load. + +**The shim is permanent.** `.github/scripts/_releaselib.py` remains as CI's stable entry point, +becoming a thin, **data-free** re-export of the generated mechanism plus a CLI that loads target data +from the declared file. It is never deleted. This deliberately avoids coupling six CI call sites to a +generated payload path. + +1. **Slice 1** lands `core/pysrc/_releaselib.py` (mechanism only) and converts the shim to re-export + it while **temporarily retaining** the data constants. Every CI consumer keeps working unchanged. +2. **Slice 4** removes the constants from the shim once the gate and workflow read the declared file. + The shim itself survives. + +The invariant is scoped, not absolute: **until slice 4 completes**, no commit may leave +`RELEASE_TAG_PREFIXES` unimportable from the shim. AC-1.9 is explicitly **transitional** — it is +superseded by AC-4.4, and the obligation test it creates (`test_releaselib_shim_exports_constants`) +is retired in the same commit that satisfies AC-4.4. + +The shim locates the declared file from its own `__file__` rather than the working directory, using +the cwd-independent pattern already at `payload_version_gate.py:56`. All six shell-out sites run at +the checkout root of a full `actions/checkout` and the file is tracked, so it is present wherever the +shim is. **An absent or unparseable file fails closed** — the loud parser error turns preflight or +the gate red, which is the correct direction for a `contents: write` publisher. Nobody should later +"fix" that into a default. + +## File grammar + +Per-target sub-blocks of `key: value` lines inside the HTML-comment delimiters `_scopelib` +recognizes. **Not a markdown table** — this repo's pre-tag commands contain pipes. + +``` + +[ca] +prefix: v +manifest: plugins/ca/.claude-plugin/plugin.json +changelog: CHANGELOG.md +payload: plugins/ca/ +rebuild: cd plugins/ca/tools && npm run build +artifacts: plugins/ca/tools/farm.js +provenance-manifest: .github/published-tags.json +latest-eligible: true +pre-tag: python3 .github/scripts/check_badge_consistency.py +pre-tag: python3 .github/scripts/check_command_catalog.py + +[ca-pi] +prefix: ca-pi-v +manifest: plugins/ca-pi/package.json +manifest: package.json +changelog: plugins/ca-pi/CHANGELOG.md +payload: plugins/ca-pi/ +payload-exclude: plugins/ca-pi/tools/ +rebuild: node plugins/ca-pi/tools/build.mjs +artifacts: plugins/ca-pi/extensions/codearbiter.js +artifacts: plugins/ca-pi/extensions/codearbiter-child.js +latest-eligible: false +pre-tag: python3 tools/build-host-packages.py --check + +``` + +A single-artifact consumer declares one block: + +``` + +[app] +prefix: v +manifest: package.json +changelog: CHANGELOG.md +payload: . + +``` + +`prefix`, `changelog`, `payload` are required. All other keys are optional. + +**Script status.** `check_badge_consistency.py` exists. `tools/build-host-packages.py --check` exists +(line 177) and is used directly rather than wrapping it in a new script. `check_command_catalog.py` +does **not** exist and must be authored (AC-2.8). + +### Parser contract + +Every case below is a declared, distinguishable error unless stated otherwise — never a silent +default, never a partial parse. + +- Values split on the **first** colon; later colons are part of the value. +- A trailing `\r` is stripped from every line before parsing. This repo has documented LF→CRLF drift + from editing on Windows, and a naive parse would turn `latest-eligible: true\r` into a value that + is not `true`, silently dropping `ca`'s Latest badge — exactly the silent-default failure the + loud-failure criteria exist to forbid. +- Booleans are exactly `true` or `false`; any other value errors. +- A duplicate scalar key within a block errors. Keys that are lists (`manifest`, `artifacts`, + `pre-tag`, `payload-exclude`) repeat by design and preserve order. +- A duplicate `[target]` block errors. +- An unknown key errors, so a typo (`latest-eligibile:`) cannot silently drop a setting. +- More than one delimiter block in the file errors. +- A value containing the literal closing delimiter errors, rather than truncating the block under + non-greedy matching as `_scopelib.py:48` would. +- The `[target]` header grammar is covered by the generic malformed-block error: an empty `[]`, a + header carrying characters outside `[A-Za-z0-9._-]`, and any key appearing before the first header + all error rather than being skipped. + +## Acceptance criteria + +Grouped by slice; each is one `tdd` Phase 1 obligation and individually testable. + +### Slice 1 — mechanism ships, data loads, this repo proven unchanged + +1.1 `core/pysrc/_releaselib.py` exists and `python tools/sync-core.py --check` passes with it in the + generated set. +1.2 The mechanism contains no literal from this repo's namespace or CI vocabulary — a denylist over + `[REPO]`, `ca-pi`, `ca-codex`, `ca-sandbox`, `plugins/`, and the tag-prefix constants. +1.3 Repo-specific defaults become required parameters: `classify_merge_readiness` takes the check + name, `last_tag_select` takes the prefix, and `select_release_target` takes the target list — + none with a default, so no module-global survives to detonate at slice 4. +1.4 `load_targets(path)` returns rows carrying `target`, `prefix`, `manifest[]`, `changelog`, + `payload`, `payload_exclude[]`, `rebuild`, `artifacts[]`, `provenance_manifest`, `pre_tag[]`, + `latest_eligible`, stdlib only. +1.5 An **absent** block raises a distinguishable declared error. +1.6 Each parser-contract violation raises its own distinguishable declared error: malformed block, + CRLF-bearing boolean, non-boolean boolean, duplicate scalar key, duplicate target block, unknown + key, multiple delimiter blocks, delimiter-in-value. +1.7 An **empty** block raises a distinguishable declared error. +1.8 Series isolation holds against loaded data: a fixture with `v1.0.0` and `ca-pi-v0.1.0` resolves + each declared prefix to its own newest tag, pre-releases excluded. +1.9 *(transitional — retired by AC-4.4)* The shim re-exports the generated mechanism and still + exposes `RELEASE_TAG_PREFIXES`; `payload_version_gate.py` imports and runs unchanged. +1.10 This repo's four rows load, and `target` and `prefix` equal the recorded pre-change constants. +1.11 **Resolution trace.** Constructed by: (a) freezing a synthetic fixture — tag list, manifest + files, a small commit graph, the four rows; (b) implementing the pre-change pre-flight as a + test-only script whose helper calls pin to the pre-change module via + `git show :.github/scripts/_releaselib.py`; (c) recording the resolved-variable + dict (`TAG_PREFIX`, `LAST_TAG`, window commit set, manifest versions, artifact list); (d) + asserting the new lane reproduces it. The old-lane script is validated once against the live + repo before freezing. The trace covers **`ca` and `ca-pi`** — `ca` alone exercises neither + `payload-exclude`, nor multiple manifests, nor the generated-root rule, which are the three + behaviors most likely to change in migration. *Honest limit:* variables that existed only as + prose still enter the old-lane script by transcription, so this narrows the oracle problem + rather than eliminating it. + +### Slice 2 — pre-tag execution (check-only, DECISION-0034) + +2.1 Declared commands execute in declared order. +2.2 A non-zero exit from any pre-tag command blocks the release. +2.3 A pre-tag command that leaves the tree dirty blocks the release; the assertion is unconditional + with no per-row opt-out. It is evaluated **before** any `rebuild` runs, so a rebuild's legitimate + bundle rewrite is never attributed to a pre-tag command. +2.4 A `pre-tag` entry exceeding 1024 characters is rejected, per ADR-0002's precedent. +2.5 `security-controls.md` carries a boundary-crossings entry naming `release-targets.md` as + operator-authored executable input. +2.6 **A new protected-write class** admits mutations of `release-targets.md` only under a fresh + authoring marker **of its own**, on the H-11 pattern (`decision-lifecycle/SKILL.md:37,55` — mint + immediately before the write, `rm -f` at lane exit; `marker_fresh` is a 30-minute mtime window). + Reusing `adr-authoring-active` is wrong in both directions: an `/adr` session could write rows, + and a row edit would arm ADR authoring. Every existing class is unusable — `context` + (`pre-write.py:68-90`) admits any write whose result keeps `arbiter: enabled` frontmatter, which + this file does not have, so every write would block; `marker` blocks outright; `audit` is + append-only; `decisions` requires a marker only `/adr` mints. **The class registers on all three + flanks**, with CONTEXT.md's guards as the template: `pre-write.py`, `pre-edit.py`'s per-class + `classify_protected` dispatch, and a `_bashguardlib` redirect/write-verb pair mirroring + `CONTEXT_REDIRECT_RE` / `CONTEXT_WRITE_RE` (lines 355-356, checked at 1011). Sanctioned minters: + `context-creation`, the back-fill lane, and `/ca:release`'s own row-edit path. +2.7 **Four-case flank test:** a Write blocks, an Edit blocks, a shell redirect and a `sed -i`-class + write verb block, and the AC-5.4 marker-fresh back-fill write succeeds. Testing the Write door + alone passes while `echo 'pre-tag: ...' >> .codearbiter/release-targets.md` still plants a + command the release lane later executes — which is the whole attack the class exists to price up. +2.8 `check_command_catalog.py` exists and asserts, without mutating, that the canonical catalog + enumerates exactly the command files and that the README table lists every one. +2.9 This repo's declared rows execute end-to-end green on a clean, reconciled tree — the criterion + that stops slice 2 passing on fixtures while the first real release blocks. +2.10 A change to a row's `pre-tag` content hash forces re-confirmation before the next execution. The + hash is minted by a sanctioned Python producer, since H-19 (`pre-write.py:63`) blocks Write-tool + writes under `.markers/`. Re-confirmation is cooperative-grade per ADR-0010, which the new ADR + states rather than implies. + +### Slice 3 — schema completeness and consumer viability + +3.1 A row declaring a manifest asserts version equality and BLOCKs on mismatch. +3.2 A row declaring no manifest proceeds with the derived tag as version source, no assertion. +3.3 A declared `rebuild` runs and every `artifacts` entry is asserted clean afterward; a stale bundle + blocks. A nondeterministic bundler makes this permanently blocking, which is acceptable since + `rebuild` is optional — but the block report must name that as the cause. +3.4 `payload-exclude` entries are excluded from the commit window, verified against `ca-pi`'s + `tools/` exclusion. +3.5 `provenance-manifest` is optional; when absent the tag-provenance recording step is skipped and + the report says so explicitly. +3.6 Interpreter resolution succeeds where `python3` is absent but `python` is present, matching the + hook layer's existing fallback. + +### Slice 4 — CI reads the declared source + +4.1 `payload_version_gate.py` derives prefixes from the declared file; no tag-prefix literal remains. +4.2 Target selection is **name-keyed**: each confirmation input carries its target name, and + selection never depends on row order. +4.3 A workflow-contract test fails when the declared target set and the workflow's inputs disagree by + name. +4.4 The data constants are removed from the shim, the shim survives as CI's entry point loading data + from the declared file, all six shell-out sites and `payload_version_gate.py` still pass, and + AC-1.9's transitional test is retired in the same commit. + +### Slice 5 — onboarding and back-fill + +5.1 `decompose` elicits intent only — tag prefix, whether a changelog is kept — and writes no row it + cannot substantiate, since it runs before any manifest or tag exists. +5.2 `context-creation` scouts candidate manifests and changelogs and writes a file that + `load_targets` accepts; the assertion is on the **written file's validity**, not skill prose. +5.3 With no declared file, the back-fill presents a detected shape and does not proceed without + explicit confirmation. +5.4 On confirmation the back-fill persists the file, and a second run reads it rather than + re-detecting. +5.5 First release after adoption: with `LAST_TAG=`, the lane offers a changelog baseline at the + adoption commit instead of BLOCKing once per pre-adoption commit missing a `CHANGELOG:` footer. +5.6 `.codearbiter/.provenance/release-targets.json` records the rows' **own referenced paths** + (`manifest`, `changelog`, each `artifacts` entry) as drift triggers. A CONTEXT.md-Scope trigger is + explicitly not used: `_provenancelib.compute_drift` compares whole-file git oids with no + section-level machinery, so it would fire on a `stage:` flip and stay silent when a manifest path + moves. Routine per-release version bumps **will** trip these triggers by design; `heal_worklist` + auto-heals them in the same release commit, and the spec records this so a later maintainer does + not delete the triggers to quiet the noise. + +### Slice 6 — surfaces agree + +6.0 **The release skill itself resolves its targets from the declared file.** Its Targets table is + replaced by `load_targets`, its helper invocations resolve under `${CLAUDE_PLUGIN_ROOT}`, its + Phase-3 tag-provenance step reads the `provenance-manifest` row field, and its hosted-lane and + immutability prose are conditional on what the consumer's repo actually has. *(Added rev 4.1 — a + review of the plan found that no criterion required rewriting the skill, so a task set could + prove bijective coverage while the campaign's central deliverable was missing.)* +6.1 A guard scans `core/surface/skills/**` and fails on any reference naming a **this-repo path the + skill executes or reads**, permitting `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PROJECT_DIR}` + prefixes and permitting repo-path *patterns* inside scout scan-target lists. The guard states its + matching rule in its own docstring. +6.2 `subagent-driven-development`'s reference resolves as `${CLAUDE_PLUGIN_ROOT}/tools/farm.js`. +6.3 `decision-lifecycle`'s line is **reworded to a conditional CI reference** rather than naming a + script the skill executes, since `check_adr_identity.py` is CI-only and is not shipped. +6.4 `commands/release.md` documents the same arguments and phase numbering as the skill. +6.5 The docs-site release guide distinguishes the general lane from this repo's configuration. +6.6 **Portability is proven in a clean consumer repo, not asserted.** A scratch git repo containing a + single `package.json`, a `CHANGELOG.md`, one `v*` tag, and an installed codeArbiter — with **no + file from this repository present** — runs `/ca:release` through target resolution, window + derivation, bump classification, and changelog rolling. *(Added rev 4.1: #563's acceptance carried + this as a checkbox but no numbered criterion existed, so no task covered it. Verifying against + this repo's own hand-built state is the documented way consumer-facing bugs stay hidden.)* +6.7 **This repo still releases.** After the migration, `/ca:release ca` reaches a composed tag on a + scratch branch with the same version the pre-change lane would have derived, and the tag is + discarded rather than published. + +## Decisions on record + +- **DECISION-0034** (supersedes 0033) — pre-tag commands are declared per row and check-only; no + assert-clean flag. Reconciliation is a separate operator action through `commit-gate`. +- Declaration home is a separate `.codearbiter/release-targets.md` rather than a `CONTEXT.md` block, + on **context economy**: `CONTEXT.md` is read every session, release config only when tagging. The + write guard is recovered explicitly by AC-2.6 and relevance by JIT enrolment. +- `context-creation` owns full elicitation; `decompose` owns intent only; release-time detection is + back-fill. + +## Splitting + +Three parts are separable and should be planned as their own clusters rather than interleaved with +the release slices. + +**The protected-write class (AC-2.6, 2.7) is the significant one, and it is not release machinery.** +It touches three hook flanks that run on every session in every consumer repo, so its blast radius is +far wider than this lane. It is also the first instance of a pattern the project is heading toward +deliberately: more project-state files that are **written by helpers rather than by inference**, with +a guard making the helper the only path. `_taskboardlib`'s `next_seq` / `add_entry` / `set_state` +already supply the helper half for `open-tasks.md`; what is missing is the guard. + +It therefore MUST be designed as **generic marker-gated project-state machinery** parameterized over +a registry of protected files, never as a `release-targets.md` special case. `release-targets.md` is +its first consumer, not its reason. A one-off implementation here would have to be torn out the first +time `open-tasks.md` adopts it. + +**AC-6.5 (docs-site guide)** has no code coupling to any slice and carries its own verification +regime (`npm test` over the generator suites), so it belongs in a docs lane. + +**AC-3.6 (interpreter fallback)** likely generalizes to any skill that shells `python3`. Keep it here, +but if implementation reveals a shared convention change, split it rather than widening this campaign +silently. + +## Open questions + +None blocking. **D-6** is narrowed: the `provenance-manifest` field now exists, so the portable lane +is coherent without relocating the file. Deferred is only whether this repo's copy physically moves +from `.github/` to `.codearbiter/`. + +## Review provenance + +Two adversarial passes. Pass 1 found the rev-1 criteria set unsatisfiable, the MVP slice ordering +CI-breaking, the drift trigger decorative, the golden test's oracle circular, the positional +`select-target` a reorder-to-mispublish hazard, and corrected the contamination measurement. Pass 2 +verified 11 of 15 repairs sound and found four defects, two introduced by the repairs themselves: +AC-2.6 was specified against a false model of the protected-write machinery and would have blocked +the very onboarding writes it sits beside, and the migration invariant contradicted AC-4.4 while +shim removal would have broken six CI call sites uncovered by any criterion. Both are fixed above. diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index 9a909c74..334348b2 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -1205,3 +1205,10 @@ Started 2026-07-20T01:40:46-04:00. Append-only. SMARTS-scored auto-decisions; - [2026-07-20] PR #313 evidence-bound candidate GREEN | candidate f764929e02fbb67b43a3b828686c0007445a0316 completed main CI run 29777295584, CodeQL run 29777291834, and docs run 29777295518 on the exact head; all six supported Pi cells, Pi security, all hook platforms, sandbox, farm, docs, generators, references, version gates, aggregate, and merge readiness pass | fresh candidate-matched local evidence: Pi 0.80.5 platform contract 104547ms, Pi 0.80.10 platform contract 103745ms, global Pi restored to 0.80.10, unchanged real 0.80.6 refusal canary VERSION_UNSUPPORTED | sanitized promotion pair regenerated for f764929; next hard boundary: strict final verifier | strength: strong | confidence: high. - [2026-07-20] hackathon PR #313 T-13 ACCEPTED | strict final verifier completed one uninterrupted green replay in an isolated feat/pi-support clone at f764929e02fbb67b43a3b828686c0007445a0316 with the exact evidence-only descendant set | evidence: 46/46 canonical repository gates, candidate-matched hosted attestation, exact sanitized Markdown rendering, PI-AC-01..38, owner inventory, final task/obligation statuses, generation idempotency, branch guard, and diff check all PASS | next hard boundary: evidence-only commit and final hosted CI before source PR closure | strength: strong | confidence: high. - [2026-07-20] hackathon PR #313 T-14 and T-15 ACCEPTED | evidence-only head 6173b1d4d2ae6380ef577fd631131c9469a898c1 completed main CI 29778968354, CodeQL 29778964688, and docs 29778968433 with all exact-head checks green | after that gate, source PRs 347,348,349,350,351,354,356,357,358,359,360,362,363,365,367,368 were closed unmerged with durable redirects to #313 | closure audit: 16/16 source states closed-unmerged, 16/16 heads match the source manifest, 16/16 redirect comments cite #313 and 6173b1d, 16/16 source numbers remain present in #313 body; target PR remains open, clean, and unmerged | CHOSEN: commit this governance-only closure receipt and require exact-head CI once more; never merge | strength: strong | confidence: high. +- [2026-07-31] DECISION portable-release-and-protected-state D-1/D-2 closure | point: the sprint closes both task-board deferrals, and D-2 carries two undecided parameters — which command owns the archival sweep of settled `[x]` items into `done-tasks.md`, and what "long-settled" means | D-1 status: already resolved in substance — `/ca:task` exists as the sanctioned board mutator with `_taskboardlib` supplying `next_seq`/`add_entry`/`set_state`; what D-1 actually left open is that the sanctioned path is not ENFORCED, which is this sprint's protected-write class, so D-1 closes as fact rather than as a decision | options for the sweep owner: `/ca:standup`; `/ca:task`; a new `/ca:archive` | SMARTS: Scalable, Available, Testable and Securable are Indifferent — all three are local, solo-scale, and the sweep is a `_taskboardlib` function with a thin caller in every case. Maintainable: standup Strong, it is already the daily-hygiene lane that performs cleanups under per-action confirmation; task Adequate, archival is periodic hygiene rather than a per-task mutation and sits oddly beside add/start/done; archive Weak, a new public surface to govern, catalog and document, which the release skill's own one-command-four-plugins reasoning argues against for a single operation. Reliable: standup Strong, its "never destructive without a yes" contract is exactly D-2's "deliberate, confirmed, append-only" requirement; task and archive Adequate | CHOSEN: `/ca:standup` owns the sweep; the cutoff is done > 14 days, matching D-2's own worked example and shipping as a named constant per D-3's precedent so tuning it stays non-load-bearing | strength: moderate — two lenses align cleanly and the rest are Indifferent | confidence: low — flagged for review; the cutoff in particular is a tuning value with no evidence behind 14 over 7 or 30, and the mechanism is tested with an injected date so the constant is not load-bearing. +- [2026-07-31] DECISION portable-release-and-protected-state B2 done-tasks policy | point: an adversarial pass established that the protected-state registry needs a per-entry policy rather than uniform marker-gating, and `done-tasks.md` had no policy chosen | options: enroll it in the existing H-05 audit set (`_protectedlib.AUDIT_LOG_FLAT_BASENAMES:63`), which supplies Write-block, tail-anchored append and a shell flank for free; or make it helper-only via the archive verb | SMARTS: Maintainable favors H-05 (zero new machinery); Reliable and Securable favor helper-only, because the audit set permits ANY cooperative append while the archive verb constrains the shape and enforces per-item append-then-remove ordering; Testable favors helper-only, since the verb is directly fixture-testable where a free-form append is not; Scalable and Available Indifferent | CHOSEN: helper-only via `taskwrite archive`, which this sprint must build regardless for the sweep to have any implementation path at all — reusing H-05 would add a second weaker route to the same file | strength: moderate | confidence: low — flagged for review; H-05 reuse is a defensible cheaper answer if the archive verb slips. +- [2026-07-31] CORRECTION portable-release-and-protected-state workstream B | the sprint spec's first draft of B1/B2/B3 was materially wrong and has been corrected before planning | what was wrong: (1) a flat marker-gated registry was specified for all three consumers, but marker-gating `open-tasks.md` would ADMIT an agent composing board markdown under a marker, inverting steer 2 — `taskwrite.py` already exists as "the ONLY blessed way to write open-tasks.md" and is flank-invisible by construction, so the correct policy is a hard block with no marker path; (2) the archival sweep had no implementation path, since `taskwrite.py`'s verbs are add/start/done with no removal and the routing table says "never delete to complete"; (3) the writer inventory estimate of seven or eight surfaces needing minters was wrong in both directions — `harvest.md` already routes promotion through `{{CMD:task}} add`, so the real conversion set is exactly two surfaces (`debug`, `context-creation`) plus one helper extension | no code had been written; the correction is spec-only and pre-planning. +- [2026-07-31] DECISION portable-release-and-protected-state pre-run gate dispositions | point: an adversarial pass on the plan enumerated every point where autonomy would halt, and the maintainer pre-answered them in one batch to maximize uninterrupted run length | T-06..T-08 hook-guard gate: CHOSEN delegate-with-tripwire — proceed when `security-reviewer` PASSes and the six flank tests B-07..B-12 are green, halt only on a finding; SMARTS moderate, Available and Testable favored delegation (the gate becomes a reproducible test verdict rather than a guaranteed stall) while Reliable and Securable genuinely favored halting, making this an explicit maintainer risk-appetite call rather than an analysis result | T-16 and T-32: CHOSEN pre-approve both contents — ADR-0024 and the `security-controls.md` boundary row are fully determined by the cleared spec, so T-32 lands without a stop and T-16 shrinks to the attribution act, which is the only inherently human part | skill rewrite (CRITICAL-1): maintainer DECLINED both offered options and specified a third — an adversarial review by an Opus-model agent mid-sprint, with maintainer review at PR time; this spends no maintainer stop, gets independent eyes on the flagship shipped artifact before dependent work builds on it, and deliberately uses a different model from the standing Fable advisor for a second perspective | nine remaining calls confirmed as a block per their SMARTS recommendations (PD-1, PD-6..PD-13) | confidence: high — every disposition was maintainer-answered rather than auto-decided; the tripwire choice is the one carrying residual risk and is recorded as such. +- [2026-07-31] DECISION portable-release-and-protected-state provenance-manifest on every row | point: the spec's grammar example declared `provenance-manifest` only on the `ca` row, and A-3.5 makes an absent field skip tag recording silently by design | options: all four rows declare it; or the example as written | SMARTS: Maintainable Strong for all-four (one rule, no per-target exception a future row copies) vs Weak (undocumented asymmetry); Reliable Strong vs Weak (three series would silently lose immutability evidence); Securable Strong vs Weak (an unrecorded tag is an unguarded tag, and `check_tag_immutability.py` warns on exactly that); Scalable and Available Indifferent | CHOSEN: all four rows declare `provenance-manifest: .github/published-tags.json`, with a per-row field assertion added to T-26 | rationale beyond SMARTS: steer 5 forbids behavior change to this repo's release lanes, and today's hard rule at SKILL.md:127 records every published tag in every namespace | strength: strong | confidence: high. +- [2026-07-31] APPROVAL portable-release-and-protected-state Phase 1 gate cleared | the maintainer approved the sprint spec and plan and authorized the FULL run, all tasks, not the MVP slice | standing directives added at approval time, recorded here because they change how every subsequent auto-decision resolves | (1) STEER 7 — determinism over suggestion: weight `Scalable` heavier than usual in SMARTS, because the project is moving work out of prose suggestions an agent may or may not honor and into deterministic helpers and hooks; the stated reasons are token reduction AND turning process rules into things enforced rather than advised, so where a call is between adding skill prose and building a helper or hook, the deterministic option wins ties | (2) adversarial passes are one pass, Opus model, medium effort — note the Agent tool pins model but has no effort parameter, so effort inherits the session setting | (3) all HIGH findings must be remediated; MEDIUM findings fixed or filed at discretion | (4) the standing adversarial advisor may be used as a MAINTAINER PROXY — questions that would otherwise stop the run may be put to it and answered as if it were the maintainer, except hard gates, which remain true stops | (5) COMPLETION BAR — the sprint is not complete when tasks are ACCEPTED; it is complete when the replacement is proven to work AND to port | confidence: high — maintainer-stated, not inferred. +- [2026-07-31] CORRECTION portable-release-and-protected-state second ledger hole | the completion bar exposed a coverage gap of the same class as A-6.0: #563's acceptance carried "a consumer repo with one artifact can run /ca:release end to end" as a prose checkbox, but neither spec held it as a numbered criterion, so no task proved the thing the entire campaign exists to deliver | this is the failure mode recorded in project memory as dev-repo-state-masks-consumer-bugs — verifying against this repo's hand-built .codearbiter/ state hides consumer-facing bugs | FIX: added A-6.6 (clean consumer repo, single package.json, one CHANGELOG.md, one v* tag, codeArbiter installed, ZERO files from this repository, running through target resolution, window derivation, bump classification and changelog rolling) and A-6.7 (this repo still composes the same version the pre-change lane would derive, tag discarded not published), covered by new tasks T-73..T-76 as a Step 8 completion proof | ledger now 72 criteria over 86 tasks | strength: strong | confidence: high. From e2e88aa08d3fc16b3d62b4cc57b7d63fcd2e8512 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Fri, 31 Jul 2026 01:24:53 -0400 Subject: [PATCH 02/66] feat(hooks): add the protected-state registry with per-entry policies (#564) The write guard three project-state files will share. Each registry entry carries a policy rather than one uniform rule, because the consumers need materially different semantics: release-targets.md is marker-gated, open-tasks.md is helper-only, done-tasks.md is append-only. open-tasks.md must not be marker-gated. taskwrite.py is already its only blessed writer, and it writes with Python file I/O whose argv never lexically names the file, so it is invisible to every flank by construction. A marker would add nothing for the helper while admitting an agent that hand-composes board markdown under it. The registry ships EMPTY. Consumers enrol in their own later tasks so this module cannot special-case its first one, and it stays code constants rather than disk-loaded state, which would let a consumer repo un-protect its own board by editing a file. An adversarial pass ran 13 mutants against the first 9 tests; 7 survived. Two could render the guard inert with a green suite: the default-registry path had only a negative assertion so a lookup that never reads REGISTRY passed, and both freshness tests computed their ages from the implementation constant, so a 33x widening of the window also passed. All 8 mutants now die. The window's five independent hardcoded declarations are documented rather than denied, and tracked in #567. Refs: #564, #567 --- .../portable-release-and-protected-state.md | 55 +++- .codearbiter/sprint-log.md | 2 + core/pysrc/_protectedstatelib.py | 244 ++++++++++++++++ plugins/ca-codex/hooks/_protectedstatelib.py | 244 ++++++++++++++++ plugins/ca-pi/hooks/_protectedstatelib.py | 244 ++++++++++++++++ plugins/ca/hooks/_protectedstatelib.py | 244 ++++++++++++++++ .../ca/hooks/tests/test_protectedstatelib.py | 266 ++++++++++++++++++ 7 files changed, 1287 insertions(+), 12 deletions(-) create mode 100644 core/pysrc/_protectedstatelib.py create mode 100644 plugins/ca-codex/hooks/_protectedstatelib.py create mode 100644 plugins/ca-pi/hooks/_protectedstatelib.py create mode 100644 plugins/ca/hooks/_protectedstatelib.py create mode 100644 plugins/ca/hooks/tests/test_protectedstatelib.py diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md index b6d21057..03e4ce0d 100644 --- a/.codearbiter/plans/portable-release-and-protected-state.md +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -105,14 +105,43 @@ Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, sh | id | path(s) | verification | maps-to | covers | depends | status | |---|---|---|---|---|---|---| -| T-01 | `core/pysrc/_protectedstatelib.py` | `python -m py_compile` passes; module imports with zero side effects | registry module exists | B-01 | — | PENDING | -| T-02 | `core/pysrc/_protectedstatelib.py`, `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_policy_enum` — all three policies present, unknown policy raises | policy enum | B-01 | T-01 | PENDING | -| T-03 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_registry_lookup` — registered path returns its policy, unregistered returns None | registry lookup | B-01 | T-02 | PENDING | -| T-04 | `core/pysrc/_hooklib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_gated_write` — fresh marker admits, absent marker blocks | marker-gated Write | B-02 | T-03 | PENDING | -| T-05 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_stale` — marker older than the window blocks | marker freshness | B-10 | T-04 | PENDING | -| T-06 | `core/pysrc/pre-write.py` | `python .github/scripts/test_hook_guards.py` — Write flank dispatches on policy | pre-write flank | B-02, B-05 | T-04 | PENDING | -| T-07 | `core/pysrc/pre-edit.py` | `python .github/scripts/test_hook_guards.py` — Edit flank blocks per class | pre-edit flank | B-03, B-05 | T-06 | PENDING | -| T-08 | `core/pysrc/_bashguardlib.py` | `python .github/scripts/test_hook_guards.py` — redirect + write-verb pair blocks | shell flank | B-04, B-05 | T-07 | PENDING | +| T-01 | `core/pysrc/_protectedstatelib.py` | `python -m py_compile` passes; module imports with zero side effects | registry module exists | B-01 | — | ACCEPTED | +| T-02 | `core/pysrc/_protectedstatelib.py`, `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_policy_enum` — all three policies present, unknown policy raises | policy enum | B-01 | T-01 | ACCEPTED | +| T-03 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_registry_lookup` — registered path returns its policy, unregistered returns None | registry lookup | B-01 | T-02 | ACCEPTED | +| T-04 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_gated_write` — fresh marker admits, absent marker blocks | marker-gated Write | B-02 | T-03 | ACCEPTED | +| T-05 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_stale` — marker older than the window blocks | marker freshness | B-10 | T-04 | ACCEPTED | +**Flank wiring design — proxy-ruled 2026-07-31, do not re-derive.** Full reasoning in `sprint-log.md`. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-05a | `core/pysrc/_protectedlib.py` | `python .github/scripts/test_hook_guards.py` — `classify_protected` returns a `"state"` class for a registered path, evaluated **after** the existing four; return contract stays a set of strings so its four consumers see no change | classifier extension | B-01 | T-05 | PENDING | +| T-05b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_no_legacy_overlap` — **no** registered path classifies into any legacy class; overlap fails loudly as a configuration error rather than resolving by precedence | overlap guard | B-01 | T-05a | PENDING | +| T-06 | `core/pysrc/pre-write.py` | `python .github/scripts/test_hook_guards.py` — one generic `"state"` branch resolves the entry's policy from the registry and applies it; no second lookup | pre-write flank | B-02, B-05 | T-05b | PENDING | +| T-07 | `core/pysrc/pre-edit.py` | `python .github/scripts/test_hook_guards.py` — same generic branch; `helper-only` blocks **unconditionally**, no marker path | pre-edit flank | B-03, B-05 | T-06 | PENDING | +| T-08 | `core/pysrc/_bashguardlib.py` | `python .github/scripts/test_hook_guards.py` — `_state_write_res(basename) -> (redirect_re, write_re)` template mirroring `CONTEXT_REDIRECT_RE`/`CONTEXT_WRITE_RE` (lines 355-359), compiled once at import from the **code-constant** registry, per entry rather than one alternation | shell flank | B-04, B-05 | T-07 | PENDING | +| T-08a | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_marker_touch_allowed` — `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; `GATE_MARKER_NAMES` gains a comment stating it enumerates **block-to-allow** markers while friction markers stay touchable by design | minting fence | B-04 | T-08 | PENDING | +| T-08b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_verb_in_description_residual` — documents that `taskwrite add -- "remember to tee open-tasks.md"` false-blocks; pins the **passing** B-08 form and records the residual rather than chasing it with smarter parsing | lexical residual | B-08 | T-08a | PENDING | + +**Rulings encoded above, with the reasoning that produced them:** + +- **The registry is code constants, never disk-loaded.** A disk registry would let a consumer repo + un-protect `open-tasks.md` by editing a file. Zero-side-effects-at-import prohibits file I/O and + git, not regex compilation from module constants (`_scopelib.py:109-117` precedent). +- **Dispatch extends `classify_protected` rather than sitting beside it.** `_protectedlib.py:13-19` + records #528/#529, where independent class checks deadlocked because set membership was + uncoordinated; and #162 symlink laundering is closed *inside* the classifier + (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms). A parallel + lookup ships without symlink resolution, and a symlink alias writes through the guard on day one. +- **`helper-only` is unconditional, with merge conflicts as a named residual.** A conflict in + `open-tasks.md` itself has no `taskwrite` verb, so resolution routes through logged + `/ca:override`. The ADR carries a reopen condition: if `gate-events.log` shows board-conflict + overrides recurring, build a `taskwrite resolve` verb — never a guard exception. A + conflict-marker content predicate was rejected as converting file content into an authorization + signal. +- **`GATE_MARKER_NAMES` is not widened.** It enumerates markers that convert a BLOCK into an ALLOW; + an authoring marker fakes nothing and is self-mintable by design (ADR-0010). The risk runs + opposite to intuition — a future generic "hardening" over every registered marker would brick + every minting lane while stopping no non-cooperative agent. T-08a is the fence against that. | T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_git_add` — `git add open-tasks.md` passes | git-verb non-regression | B-07 | T-08 | PENDING | | T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_filename_as_data` — helper call with filename in description passes | argv-data non-regression | B-08 | T-08 | PENDING | | T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_shell_writes_block` — `tee` and `>>` both block | shell-write blocking | B-09 | T-08 | PENDING | @@ -283,14 +312,16 @@ A-2.10→T-36, A-3.1→T-37, A-3.2→T-38, A-3.3→T-39, A-3.4→T-40, A-3.5→T A-4.1→T-43, A-4.2→T-44a/T-44b, A-4.3→T-45, A-4.4→T-46, A-5.1→T-47, A-5.2→T-48, A-5.3→T-49, A-5.4→T-50, A-5.5→T-51, A-5.6→T-52, **A-6.0→T-41a/T-41b/T-41c/T-41d/T-41e**, A-6.1→T-68a/T-68b, A-6.2→T-69, A-6.3→T-70, A-6.4→T-71, A-6.5→T-72, **A-6.6→T-73/T-74/T-75, A-6.7→T-76**. -B-01→T-01/02/03/15, B-02→T-04/T-06, B-03→T-07, B-04→T-08, B-05→T-06/07/08/T-67, B-06→T-13, -B-07→T-09, B-08→T-10, B-09→T-11, B-10→T-05, B-11→T-14, B-12→T-12/T-67, B-13→T-33, B-14→T-66, +B-01→T-01/02/03/15/T-05a/T-05b, B-02→T-04/T-06, B-03→T-07, B-04→T-08/T-08a, B-05→T-06/07/08/T-67, +B-06→T-13, B-07→T-09, B-08→T-10/T-08b, B-09→T-11, B-10→T-05, B-11→T-14, B-12→T-12/T-67, B-13→T-33, B-14→T-66, B-15→T-65, B-16→T-54, B-17→T-53, B-18→T-55, B-19→T-56/T-67, B-20→T-57/T-58, B-21→T-59, B-22→T-60, B-23→T-58/T-61, B-24→T-64, B-25→T-62, B-26→T-63, B-27→T-16. -**Every task → at least one AC.** Verified across all 86 tasks; no task covers nothing. +**Every task → at least one AC.** Verified across all 90 tasks; no task covers nothing. -Bijective coverage proven: **72 criteria, 86 tasks**, no uncovered criterion and no orphan task. +Bijective coverage proven: **72 criteria, 90 tasks**, no uncovered criterion and no orphan task. +(90 rather than 86 after the proxy rulings added T-05a, T-05b, T-08a, T-08b — the classifier +extension, the legacy-overlap guard, the marker-minting fence, and the lexical-residual pin.) *Rev 2/3 note — two holes were in the ledger, not the task set.* The first draft claimed bijection over 69 criteria and 72 tasks. The claim was formally true and hollow both times: A-6.1 mapped to the diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index 334348b2..bfc4204d 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -1212,3 +1212,5 @@ Started 2026-07-20T01:40:46-04:00. Append-only. SMARTS-scored auto-decisions; - [2026-07-31] DECISION portable-release-and-protected-state provenance-manifest on every row | point: the spec's grammar example declared `provenance-manifest` only on the `ca` row, and A-3.5 makes an absent field skip tag recording silently by design | options: all four rows declare it; or the example as written | SMARTS: Maintainable Strong for all-four (one rule, no per-target exception a future row copies) vs Weak (undocumented asymmetry); Reliable Strong vs Weak (three series would silently lose immutability evidence); Securable Strong vs Weak (an unrecorded tag is an unguarded tag, and `check_tag_immutability.py` warns on exactly that); Scalable and Available Indifferent | CHOSEN: all four rows declare `provenance-manifest: .github/published-tags.json`, with a per-row field assertion added to T-26 | rationale beyond SMARTS: steer 5 forbids behavior change to this repo's release lanes, and today's hard rule at SKILL.md:127 records every published tag in every namespace | strength: strong | confidence: high. - [2026-07-31] APPROVAL portable-release-and-protected-state Phase 1 gate cleared | the maintainer approved the sprint spec and plan and authorized the FULL run, all tasks, not the MVP slice | standing directives added at approval time, recorded here because they change how every subsequent auto-decision resolves | (1) STEER 7 — determinism over suggestion: weight `Scalable` heavier than usual in SMARTS, because the project is moving work out of prose suggestions an agent may or may not honor and into deterministic helpers and hooks; the stated reasons are token reduction AND turning process rules into things enforced rather than advised, so where a call is between adding skill prose and building a helper or hook, the deterministic option wins ties | (2) adversarial passes are one pass, Opus model, medium effort — note the Agent tool pins model but has no effort parameter, so effort inherits the session setting | (3) all HIGH findings must be remediated; MEDIUM findings fixed or filed at discretion | (4) the standing adversarial advisor may be used as a MAINTAINER PROXY — questions that would otherwise stop the run may be put to it and answered as if it were the maintainer, except hard gates, which remain true stops | (5) COMPLETION BAR — the sprint is not complete when tasks are ACCEPTED; it is complete when the replacement is proven to work AND to port | confidence: high — maintainer-stated, not inferred. - [2026-07-31] CORRECTION portable-release-and-protected-state second ledger hole | the completion bar exposed a coverage gap of the same class as A-6.0: #563's acceptance carried "a consumer repo with one artifact can run /ca:release end to end" as a prose checkbox, but neither spec held it as a numbered criterion, so no task proved the thing the entire campaign exists to deliver | this is the failure mode recorded in project memory as dev-repo-state-masks-consumer-bugs — verifying against this repo's hand-built .codearbiter/ state hides consumer-facing bugs | FIX: added A-6.6 (clean consumer repo, single package.json, one CHANGELOG.md, one v* tag, codeArbiter installed, ZERO files from this repository, running through target resolution, window derivation, bump classification and changelog rolling) and A-6.7 (this repo still composes the same version the pre-change lane would derive, tag discarded not published), covered by new tasks T-73..T-76 as a Step 8 completion proof | ledger now 72 criteria over 86 tasks | strength: strong | confidence: high. +- [2026-07-31] PROXY-RULING portable-release-and-protected-state T-06..T-08 flank wiring | answered by the standing adversarial advisor under the maintainer-proxy authorization; four design questions resolved so the flank wiring does not stall, two of them by rejecting my framing | Q1 policy dispatch: CHOSEN extend `_hooklib.classify_protected` to return a new `"state"` class, with policy semantics resolved from the registry inside a single generic flank branch — NOT a parallel lookup; grounded in `_protectedlib.py:13-19`, which documents the #528/#529 failure where independent class checks on decision-log.md deadlocked because set membership was uncoordinated, and in #162 symlink laundering being closed INSIDE classify_protected (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms), so a parallel lookup would ship without symlink resolution and a symlink alias would write through the guard on day one; SMARTS strong, Scalable/Maintainable/Reliable/Testable/Securable all Strong for extend vs Weak for parallel; riders: `"state"` evaluates after the existing four classes, a registry-load test asserts NO registered path classifies into any legacy class (overlap is a loud configuration error, not a precedence puzzle), and classify_protected's return contract stays a set of strings so its four existing consumers see zero change | Q2 helper-only absoluteness: CHOSEN unconditional block, confirmed safe against the full producer inventory (taskwrite.py and init-codearbiter.py use Python file I/O; boardsync.py is write-free by its own header; session-start.py reads; every skill lane routes through the task command after T-54/T-55) — but the advisor surfaced a case I had not asked about: a MERGE OR REBASE CONFLICT IN open-tasks.md ITSELF, where the resolver must edit the working-tree file and taskwrite has no resolve verb, so an absolute block turns every board conflict into a stop; ruling: block STANDS, conflict handled as a named residual routed through logged `/ca:override`, with an ADR reopen condition — if gate-events.log shows board-conflict overrides recurring, the fix is a deterministic `taskwrite resolve` verb, never a guard exception; a conflict-marker content predicate ("admit Edit when the file contains <<<<<<<") was rejected as converting file content into an authorization signal, the same launderable shape the T-55 file-absent exemption lost on | Q3 shell-flank parameterization: PREMISE REJECTED — the registry must be CODE CONSTANTS and never disk-loaded, because a disk registry would let a consumer repo un-protect open-tasks.md by editing a file; the zero-side-effects-at-import rule prohibits file I/O and git, never regex compilation from module constants, with `_scopelib.py:109-117` as direct precedent (default glob tuples precompiled at load); given that, CHOSEN per-entry regex pairs from a template `_state_write_res(basename) -> (redirect_re, write_re)` mirroring CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE shapes at `_bashguardlib.py:355-359`, compiled once at import, rather than one generic alternation whose single failure smears across all entries; verified mechanically against both pinned non-regressions (`git add open-tasks.md` passes — git is not a verb and no redirect precedes the name; the filename-in-description helper call passes — python3/taskwrite/add are not verbs; `tee` and `>>` both block); ONE RESIDUAL to document with a test rather than fix: a description containing a write verb (`taskwrite add -- "remember to tee open-tasks.md"`) false-blocks, which is the same accepted lexical residual every _bashguardlib flank carries — name it in the ADR, pin the passing form, do not chase it with smarter parsing | Q4 marker carve-out: FRAMING REJECTED — there is no exclusion to extend; `_protectedlib.py:109-113` blocks GATE_MARKER_NAMES (security-gate-passed, migration-gate-passed) only, and adr-authoring-active was never enumerated into it, because the membership criterion is whether a marker converts a BLOCK into an ALLOW; a forged gate-pass fakes a security verdict, a forged authoring marker fakes nothing since it is audit friction, self-mintable by design per ADR-0010; CHOSEN change nothing, add no generic marker handling, and instead encode the CRITERION rather than the membership — one comment on GATE_MARKER_NAMES stating that the list enumerates block-to-allow markers while friction/authoring markers stay shell-touchable by design, plus one pinning test asserting `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; the accidental-widening risk runs OPPOSITE to my question — the danger is a future maintainer "hardening" the blocklist generically over every registered marker, which would brick every minting lane while stopping no non-cooperative agent, and the pinning test is the fence against that tidy-up | confidence: high — all four grounded in cited code, two correcting my premise; none touches a non-delegable hard gate. +- [2026-07-31] REVIEW portable-release-and-protected-state B1 slice (T-01..T-05) | Opus adversarial pass per the maintainer's standard; 13-mutant campaign against the 9 new tests, 7 survivors, 2 HIGH | verdict: no live defect and no security hole — traversal safety, freshness delegation, genericity and zero-side-effects-at-import all survived attack, the latter verified under sys.addaudithook rather than assumed — but TEST ADEQUACY failed against the standing "every new test must die to a mutant" directive | HIGH-1: the default-registry code path has no fail-detecting test; mutating `registry = REGISTRY` to `registry = {}` passes all 9, because the only default-path test is a NEGATIVE assertion against one literal path and all three positive tests inject a synthetic registry — meaning nothing proves the production path works, and once consumers enrol at B-13/14/15 the entire guard could be a silent no-op with CI green | HIGH-2: the freshness window is a tautology in both directions; MARKER_FRESHNESS_MINUTES mutated to 1000 or 3 passes all 9, because both freshness tests compute their ages FROM the implementation constant, pinning boundary logic but not value — a 33x widening of a security window would ship green; compounded by a comment claiming the constant is "reused verbatim (not re-declared)" when 30 is in fact hardcoded in five independent places with no import relationship | ORCHESTRATOR ERROR, recorded deliberately: MEDIUM-3 established that my own earlier "fix" was wrong. I removed `REGISTRY: dict[str, ProtectedPolicy]` believing a PEP 585 builtin generic is evaluated at import and raises on Python 3.8, and reported that to the maintainer as a confirmed defect CI could not catch. The module carries `from __future__ import annotations` at line 84, which under PEP 563 leaves VARIABLE annotations unevaluated as well — verified by probe: the annotation with an undefined name raises nothing. The annotation was always safe on 3.8. Worse than the unnecessary change, the comment I left asserted "Every sibling _*lib.py declares module dicts bare for the same reason", a false rule that would have propagated into the next four modules of this campaign. Remediation restores the annotation and deletes the comment | remaining MEDIUMs sent for fix rather than filing, because all four propagate into the next four modules: a typo'd registry KEY fails open and silent (norm_path applied to the query, never the keys) which directly contradicts the module's own stated doctrine that malformed input must fail loud; the `str` mixin is entirely untested and its docstring's argv claim is false on 3.11+ where interpolation yields the class-qualified name and varies by interpreter version; `marker_name_for` is basename-only so two registered files sharing a stem share one authoring marker; and its norm_path call is untested | LOWs at discretion: dead `minutes` parameter, and the call-site constraint (module must be reached through classify_protected, never called directly by a flank) documented only in this log | confidence: high — every finding reproduced by executed mutation, not asserted. diff --git a/core/pysrc/_protectedstatelib.py b/core/pysrc/_protectedstatelib.py new file mode 100644 index 00000000..82c87561 --- /dev/null +++ b/core/pysrc/_protectedstatelib.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# codeArbiter - protected-state registry: which project-state files are +# guarded, and under what POLICY (issue #564, workstream B1). +# +# Three consumers need materially different write semantics, so a flat +# marker-gated registry (the H-11 ADR-authoring pattern) is wrong for two of +# them. Each registry entry will carry exactly one of: +# +# marker-gated - a Write/Edit/shell mutation is admitted only under a +# fresh authoring marker (the H-11 pattern: a +# `-authoring` marker under .codearbiter/.markers/, +# minted immediately before the write and removed at lane +# exit). First consumer: release-targets.md. +# helper-only - Write, Edit, and shell naming the file are hard-blocked +# with NO marker path at all. The sanctioned helper's own +# Python file I/O is the only route - its argv never +# lexically names the file, so it is invisible to all three +# flanks by construction. Consumer: open-tasks.md via +# taskwrite.py. +# append-only - mutation is admitted only via the helper's append verb. +# Consumer: done-tasks.md via the archive verb. +# +# THE REGISTRY IS THE DELIVERABLE, NOT THE ENTRIES (spec B1). This module is +# generic machinery over a policy-tagged path->policy map; it enrolls NO +# consumer itself. release-targets.md, open-tasks.md, and done-tasks.md are +# registered by their own later tasks (B-13/B-14/B-15) so this module never +# special-cases its first consumer. +# +# Library design invariants (mirrors every other _*lib.py, coding-standards.md): +# - Zero side effects at import time - no git calls, no file I/O on import. +# - Pure functions, testable with synthetic input; filesystem access will be +# isolated to `_hooklib.marker_fresh`, the one named reader this module +# calls (reused rather than re-implemented, per house rule). +# +# Public API (filled in task-by-task; see history for the T-01/T-04/T-05 +# slices): +# ProtectedPolicy enum: MARKER_GATED / HELPER_ONLY / +# APPEND_ONLY, str-valued +# ("marker-gated"/"helper-only"/ +# "append-only"). Constructing from an +# unknown value raises ValueError +# (internal error, not user input). +# REGISTRY -> dict[str, ProtectedPolicy] the live path->policy map. +# Starts EMPTY here; consumers are +# registered by their own tasks, never +# hardcoded in this module. +# lookup_policy(rel_path, registry=None) -> ProtectedPolicy | None +# the policy registered for a +# repo-relative path. Both the query +# path and every registry key are +# separator-normalized before +# comparison, or None if the path +# carries no policy. `registry` defaults +# to the module-level REGISTRY; a caller +# (a test, or a future flank) may pass a +# synthetic dict instead. Reached only +# through `classify_protected` +# (_protectedlib.py) in the wired path - +# never called directly with an ad hoc +# dict, which would drop the +# raw-and-realpath symlink coverage that +# dispatch provides. +# MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches +# the ADR-authoring gate's value by +# convention, not a shared import - see +# the constant's own comment for the +# five independent declarations. +# marker_name_for(rel_path) -> str a marker basename for a marker-gated +# path, encoding the path BELOW the +# repo's .codearbiter/ project-state +# root (not just the basename) so +# same-named files in different +# sub-directories never share a marker +# (e.g. ".codearbiter/release-targets.md" +# -> "release-targets-authoring", the +# pinned literal for the sole current +# consumer; "docs/release-targets.md" +# -> "docs__release-targets-authoring"). +# Always a single flat, traversal-safe +# filename; degenerate input (empty, +# None, a bare directory) never raises +# and never collides with a real path's +# derived name. +# marker_gated_write_admitted(rel_path, root, +# minutes=MARKER_FRESHNESS_MINUTES) -> bool +# True iff a fresh `-authoring` +# marker exists under +# .codearbiter/.markers/ for `rel_path`. +# False on an absent marker AND on a +# stale one - both cases delegate +# entirely to _hooklib.marker_fresh, so +# there is exactly one freshness +# implementation in the codebase. For +# `marker-gated` only; the flank wiring +# (pre-write.py, pre-edit.py, +# _bashguardlib.py per-class dispatch) +# is hook ID H-22 (H-21 is taken), +# built in later tasks of this slice - +# this function supplies the check the +# flanks will share, and does not +# itself call block()/remind(). + +from __future__ import annotations + +import os +from enum import Enum + +from _hooklib import marker_fresh +from _pathnorm import norm_path + + +class ProtectedPolicy(str, Enum): + """The three write-admission policies a protected-state registry entry + can carry (spec B1). str-valued so a member compares equal to its plain + string value (`ProtectedPolicy.MARKER_GATED == "marker-gated"`) and + round-trips through JSON without a second mapping layer. + + Interpolation caveat: use `.value`, not the member itself. `str(member)` / + an f-string / `%s` on a bare member yields `"ProtectedPolicy.MARKER_GATED"` + on Python 3.11+ (and the plain value on <=3.10) - the mixin does not make + those two forms agree across interpreters. Only `.value` (or the + already-proven `==` against a plain string) is the stable contract. + + Constructing an unknown value (`ProtectedPolicy("bogus")`) raises + ValueError via the stdlib Enum machinery - deliberately NOT caught here. + An unrecognized policy string reaching this constructor is a typo'd + registry entry - an internal programming error, not malformed user + input - so it is correct to raise rather than degrade.""" + + MARKER_GATED = "marker-gated" + HELPER_ONLY = "helper-only" + APPEND_ONLY = "append-only" + + +# The live protected-state registry: repo-relative path (separator- +# normalized) -> ProtectedPolicy. Deliberately EMPTY here - B1 (this module) +# ships the registry mechanism, not entries. release-targets.md/ +# open-tasks.md/done-tasks.md are added by their own later tasks +# (B-13/B-14/B-15), each a one-line entry, which is the whole point of +# building this as a registry instead of a per-file hook branch. +REGISTRY: dict[str, ProtectedPolicy] = {} + + +def lookup_policy(rel_path, registry=None): + """The ProtectedPolicy registered for `rel_path`, or None if it carries + no policy. Both `rel_path` and every registry key are separator- + normalized before comparison, so a Windows backslash path matches a + registry keyed with forward slashes (the same normalization every other + _*lib.py classifier applies via norm_path) AND a registry entry that was + itself typo'd with a backslash, a `./` prefix character, or a trailing + slash still matches rather than silently protecting nothing - a + malformed *key* degrades the same way a malformed query path does; only a + malformed *policy* (see ProtectedPolicy) is an internal error worth + raising on. + + `registry` defaults to the module-level REGISTRY; a test (or a future + caller) may pass a synthetic dict instead, which is what keeps this + function generic machinery rather than something wired to a specific + consumer set. This module must be reached through `classify_protected` + (`_protectedlib.py`), which runs every classifier against both the raw + and realpath-resolved forms of a path - a flank that calls + `lookup_policy` directly with its own ad hoc dict re-opens the symlink + alias this module does not itself guard against.""" + if registry is None: + registry = REGISTRY + normalized = norm_path(rel_path) + for key, policy in registry.items(): + if norm_path(key) == normalized: + return policy + return None + + +# The H-11 authoring-marker freshness window, matching the existing +# ADR-authoring gate's value by convention, NOT by a shared import: +# pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each +# independently hardcode `30` for the same marker shape, and this is a fifth, +# equally independent, declaration. Widening this constant does not widen +# theirs, and widening theirs does not widen this one - there is no single +# source of truth for the window's value across those five sites today. +MARKER_FRESHNESS_MINUTES = 30 + + +def marker_name_for(rel_path): + """The `<...>-authoring` marker basename for a marker-gated `rel_path` + (e.g. "release-targets.md" -> "release-targets-authoring") - the pattern + named in the sprint's pre-run dispositions for every future marker-gated + consumer, not just the first. + + Encodes the normalized relative path below the repo's single + `.codearbiter/` project-state root, not just the basename, so two + registry entries that merely share a filename in different + sub-directories (".codearbiter/release-targets.md" vs + ".codearbiter/nested/release-targets.md", or "a/x.md" vs "b/x.yml") mint + two distinct markers rather than one shared one - minting the marker for + one would otherwise admit a write to the other. A single leading + `.codearbiter` segment is dropped before encoding rather than treated as + disambiguating structure: every registry entry lives there by + construction (this module's whole domain), so keeping it out of the + encoding is what leaves the pinned literal for the sole current + marker-gated consumer unchanged (`.codearbiter/release-targets.md` -> + `release-targets-authoring`) while still telling apart two DIFFERENT + sub-directories. A literal `-` inside a directory segment is escaped + (doubled) before segments are joined with `-`, so a raw `-` in the + encoded name always marks a genuine directory boundary - this closes the + same-string collision a naive join would allow between, e.g., a + "prefix-name" directory and a "prefix"/"name" nested pair. `.`, `..`, and + empty segments are dropped before encoding (not merely trusted to + `os.path.basename`), so a stray `./` prefix, a trailing slash, or a + crafted `../` segment can never reach the returned name; the result is + always a single flat filename, never a path, so it stays contained under + .codearbiter/.markers/. Never raises: an empty/None `rel_path`, or one + with no real path segment (".", "..", "/"), has no directory or file to + encode and degrades to the fixed sentinel "-authoring" - a name no real + (non-empty) rel_path can ever produce, since a real segment always + contributes a non-empty stem. + + Residual (accepted): the escaping above closes hyphen/segment-boundary + ambiguity but is not a fully bijective encoding against adversarially + crafted underscore runs (e.g. a directory literally named "a_" holding + "_b.md" can alias "a" holding "__b.md"). The registry's entries are a + small, curated, human-authored set (spec B1/B2), not attacker-chosen + directory names, so this is judged out of proportion to close fully + here.""" + parts = [p for p in norm_path(rel_path).split("/") if p not in ("", ".", "..")] + if not parts: + return "-authoring" + if parts[0] == ".codearbiter" and len(parts) > 1: + parts = parts[1:] + stem = os.path.splitext(parts[-1])[0] + dir_tag = "-".join(p.replace("-", "--") for p in parts[:-1]) + encoded = f"{dir_tag}__{stem}" if dir_tag else stem + return f"{encoded}-authoring" + + +def marker_gated_write_admitted(rel_path, root, minutes=MARKER_FRESHNESS_MINUTES): + """True iff a Write/Edit/shell mutation of a `marker-gated` `rel_path` is + admitted: a `-authoring` marker exists under + .codearbiter/.markers/ and was touched within `minutes` (the H-11 + pattern). False on an absent marker AND on a stale one - both cases + delegate entirely to _hooklib.marker_fresh (the one filesystem reader + this module calls), so there is exactly one freshness implementation in + the codebase.""" + marker = os.path.join(root, ".codearbiter", ".markers", marker_name_for(rel_path)) + return marker_fresh(marker, minutes) diff --git a/plugins/ca-codex/hooks/_protectedstatelib.py b/plugins/ca-codex/hooks/_protectedstatelib.py new file mode 100644 index 00000000..82c87561 --- /dev/null +++ b/plugins/ca-codex/hooks/_protectedstatelib.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# codeArbiter - protected-state registry: which project-state files are +# guarded, and under what POLICY (issue #564, workstream B1). +# +# Three consumers need materially different write semantics, so a flat +# marker-gated registry (the H-11 ADR-authoring pattern) is wrong for two of +# them. Each registry entry will carry exactly one of: +# +# marker-gated - a Write/Edit/shell mutation is admitted only under a +# fresh authoring marker (the H-11 pattern: a +# `-authoring` marker under .codearbiter/.markers/, +# minted immediately before the write and removed at lane +# exit). First consumer: release-targets.md. +# helper-only - Write, Edit, and shell naming the file are hard-blocked +# with NO marker path at all. The sanctioned helper's own +# Python file I/O is the only route - its argv never +# lexically names the file, so it is invisible to all three +# flanks by construction. Consumer: open-tasks.md via +# taskwrite.py. +# append-only - mutation is admitted only via the helper's append verb. +# Consumer: done-tasks.md via the archive verb. +# +# THE REGISTRY IS THE DELIVERABLE, NOT THE ENTRIES (spec B1). This module is +# generic machinery over a policy-tagged path->policy map; it enrolls NO +# consumer itself. release-targets.md, open-tasks.md, and done-tasks.md are +# registered by their own later tasks (B-13/B-14/B-15) so this module never +# special-cases its first consumer. +# +# Library design invariants (mirrors every other _*lib.py, coding-standards.md): +# - Zero side effects at import time - no git calls, no file I/O on import. +# - Pure functions, testable with synthetic input; filesystem access will be +# isolated to `_hooklib.marker_fresh`, the one named reader this module +# calls (reused rather than re-implemented, per house rule). +# +# Public API (filled in task-by-task; see history for the T-01/T-04/T-05 +# slices): +# ProtectedPolicy enum: MARKER_GATED / HELPER_ONLY / +# APPEND_ONLY, str-valued +# ("marker-gated"/"helper-only"/ +# "append-only"). Constructing from an +# unknown value raises ValueError +# (internal error, not user input). +# REGISTRY -> dict[str, ProtectedPolicy] the live path->policy map. +# Starts EMPTY here; consumers are +# registered by their own tasks, never +# hardcoded in this module. +# lookup_policy(rel_path, registry=None) -> ProtectedPolicy | None +# the policy registered for a +# repo-relative path. Both the query +# path and every registry key are +# separator-normalized before +# comparison, or None if the path +# carries no policy. `registry` defaults +# to the module-level REGISTRY; a caller +# (a test, or a future flank) may pass a +# synthetic dict instead. Reached only +# through `classify_protected` +# (_protectedlib.py) in the wired path - +# never called directly with an ad hoc +# dict, which would drop the +# raw-and-realpath symlink coverage that +# dispatch provides. +# MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches +# the ADR-authoring gate's value by +# convention, not a shared import - see +# the constant's own comment for the +# five independent declarations. +# marker_name_for(rel_path) -> str a marker basename for a marker-gated +# path, encoding the path BELOW the +# repo's .codearbiter/ project-state +# root (not just the basename) so +# same-named files in different +# sub-directories never share a marker +# (e.g. ".codearbiter/release-targets.md" +# -> "release-targets-authoring", the +# pinned literal for the sole current +# consumer; "docs/release-targets.md" +# -> "docs__release-targets-authoring"). +# Always a single flat, traversal-safe +# filename; degenerate input (empty, +# None, a bare directory) never raises +# and never collides with a real path's +# derived name. +# marker_gated_write_admitted(rel_path, root, +# minutes=MARKER_FRESHNESS_MINUTES) -> bool +# True iff a fresh `-authoring` +# marker exists under +# .codearbiter/.markers/ for `rel_path`. +# False on an absent marker AND on a +# stale one - both cases delegate +# entirely to _hooklib.marker_fresh, so +# there is exactly one freshness +# implementation in the codebase. For +# `marker-gated` only; the flank wiring +# (pre-write.py, pre-edit.py, +# _bashguardlib.py per-class dispatch) +# is hook ID H-22 (H-21 is taken), +# built in later tasks of this slice - +# this function supplies the check the +# flanks will share, and does not +# itself call block()/remind(). + +from __future__ import annotations + +import os +from enum import Enum + +from _hooklib import marker_fresh +from _pathnorm import norm_path + + +class ProtectedPolicy(str, Enum): + """The three write-admission policies a protected-state registry entry + can carry (spec B1). str-valued so a member compares equal to its plain + string value (`ProtectedPolicy.MARKER_GATED == "marker-gated"`) and + round-trips through JSON without a second mapping layer. + + Interpolation caveat: use `.value`, not the member itself. `str(member)` / + an f-string / `%s` on a bare member yields `"ProtectedPolicy.MARKER_GATED"` + on Python 3.11+ (and the plain value on <=3.10) - the mixin does not make + those two forms agree across interpreters. Only `.value` (or the + already-proven `==` against a plain string) is the stable contract. + + Constructing an unknown value (`ProtectedPolicy("bogus")`) raises + ValueError via the stdlib Enum machinery - deliberately NOT caught here. + An unrecognized policy string reaching this constructor is a typo'd + registry entry - an internal programming error, not malformed user + input - so it is correct to raise rather than degrade.""" + + MARKER_GATED = "marker-gated" + HELPER_ONLY = "helper-only" + APPEND_ONLY = "append-only" + + +# The live protected-state registry: repo-relative path (separator- +# normalized) -> ProtectedPolicy. Deliberately EMPTY here - B1 (this module) +# ships the registry mechanism, not entries. release-targets.md/ +# open-tasks.md/done-tasks.md are added by their own later tasks +# (B-13/B-14/B-15), each a one-line entry, which is the whole point of +# building this as a registry instead of a per-file hook branch. +REGISTRY: dict[str, ProtectedPolicy] = {} + + +def lookup_policy(rel_path, registry=None): + """The ProtectedPolicy registered for `rel_path`, or None if it carries + no policy. Both `rel_path` and every registry key are separator- + normalized before comparison, so a Windows backslash path matches a + registry keyed with forward slashes (the same normalization every other + _*lib.py classifier applies via norm_path) AND a registry entry that was + itself typo'd with a backslash, a `./` prefix character, or a trailing + slash still matches rather than silently protecting nothing - a + malformed *key* degrades the same way a malformed query path does; only a + malformed *policy* (see ProtectedPolicy) is an internal error worth + raising on. + + `registry` defaults to the module-level REGISTRY; a test (or a future + caller) may pass a synthetic dict instead, which is what keeps this + function generic machinery rather than something wired to a specific + consumer set. This module must be reached through `classify_protected` + (`_protectedlib.py`), which runs every classifier against both the raw + and realpath-resolved forms of a path - a flank that calls + `lookup_policy` directly with its own ad hoc dict re-opens the symlink + alias this module does not itself guard against.""" + if registry is None: + registry = REGISTRY + normalized = norm_path(rel_path) + for key, policy in registry.items(): + if norm_path(key) == normalized: + return policy + return None + + +# The H-11 authoring-marker freshness window, matching the existing +# ADR-authoring gate's value by convention, NOT by a shared import: +# pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each +# independently hardcode `30` for the same marker shape, and this is a fifth, +# equally independent, declaration. Widening this constant does not widen +# theirs, and widening theirs does not widen this one - there is no single +# source of truth for the window's value across those five sites today. +MARKER_FRESHNESS_MINUTES = 30 + + +def marker_name_for(rel_path): + """The `<...>-authoring` marker basename for a marker-gated `rel_path` + (e.g. "release-targets.md" -> "release-targets-authoring") - the pattern + named in the sprint's pre-run dispositions for every future marker-gated + consumer, not just the first. + + Encodes the normalized relative path below the repo's single + `.codearbiter/` project-state root, not just the basename, so two + registry entries that merely share a filename in different + sub-directories (".codearbiter/release-targets.md" vs + ".codearbiter/nested/release-targets.md", or "a/x.md" vs "b/x.yml") mint + two distinct markers rather than one shared one - minting the marker for + one would otherwise admit a write to the other. A single leading + `.codearbiter` segment is dropped before encoding rather than treated as + disambiguating structure: every registry entry lives there by + construction (this module's whole domain), so keeping it out of the + encoding is what leaves the pinned literal for the sole current + marker-gated consumer unchanged (`.codearbiter/release-targets.md` -> + `release-targets-authoring`) while still telling apart two DIFFERENT + sub-directories. A literal `-` inside a directory segment is escaped + (doubled) before segments are joined with `-`, so a raw `-` in the + encoded name always marks a genuine directory boundary - this closes the + same-string collision a naive join would allow between, e.g., a + "prefix-name" directory and a "prefix"/"name" nested pair. `.`, `..`, and + empty segments are dropped before encoding (not merely trusted to + `os.path.basename`), so a stray `./` prefix, a trailing slash, or a + crafted `../` segment can never reach the returned name; the result is + always a single flat filename, never a path, so it stays contained under + .codearbiter/.markers/. Never raises: an empty/None `rel_path`, or one + with no real path segment (".", "..", "/"), has no directory or file to + encode and degrades to the fixed sentinel "-authoring" - a name no real + (non-empty) rel_path can ever produce, since a real segment always + contributes a non-empty stem. + + Residual (accepted): the escaping above closes hyphen/segment-boundary + ambiguity but is not a fully bijective encoding against adversarially + crafted underscore runs (e.g. a directory literally named "a_" holding + "_b.md" can alias "a" holding "__b.md"). The registry's entries are a + small, curated, human-authored set (spec B1/B2), not attacker-chosen + directory names, so this is judged out of proportion to close fully + here.""" + parts = [p for p in norm_path(rel_path).split("/") if p not in ("", ".", "..")] + if not parts: + return "-authoring" + if parts[0] == ".codearbiter" and len(parts) > 1: + parts = parts[1:] + stem = os.path.splitext(parts[-1])[0] + dir_tag = "-".join(p.replace("-", "--") for p in parts[:-1]) + encoded = f"{dir_tag}__{stem}" if dir_tag else stem + return f"{encoded}-authoring" + + +def marker_gated_write_admitted(rel_path, root, minutes=MARKER_FRESHNESS_MINUTES): + """True iff a Write/Edit/shell mutation of a `marker-gated` `rel_path` is + admitted: a `-authoring` marker exists under + .codearbiter/.markers/ and was touched within `minutes` (the H-11 + pattern). False on an absent marker AND on a stale one - both cases + delegate entirely to _hooklib.marker_fresh (the one filesystem reader + this module calls), so there is exactly one freshness implementation in + the codebase.""" + marker = os.path.join(root, ".codearbiter", ".markers", marker_name_for(rel_path)) + return marker_fresh(marker, minutes) diff --git a/plugins/ca-pi/hooks/_protectedstatelib.py b/plugins/ca-pi/hooks/_protectedstatelib.py new file mode 100644 index 00000000..82c87561 --- /dev/null +++ b/plugins/ca-pi/hooks/_protectedstatelib.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# codeArbiter - protected-state registry: which project-state files are +# guarded, and under what POLICY (issue #564, workstream B1). +# +# Three consumers need materially different write semantics, so a flat +# marker-gated registry (the H-11 ADR-authoring pattern) is wrong for two of +# them. Each registry entry will carry exactly one of: +# +# marker-gated - a Write/Edit/shell mutation is admitted only under a +# fresh authoring marker (the H-11 pattern: a +# `-authoring` marker under .codearbiter/.markers/, +# minted immediately before the write and removed at lane +# exit). First consumer: release-targets.md. +# helper-only - Write, Edit, and shell naming the file are hard-blocked +# with NO marker path at all. The sanctioned helper's own +# Python file I/O is the only route - its argv never +# lexically names the file, so it is invisible to all three +# flanks by construction. Consumer: open-tasks.md via +# taskwrite.py. +# append-only - mutation is admitted only via the helper's append verb. +# Consumer: done-tasks.md via the archive verb. +# +# THE REGISTRY IS THE DELIVERABLE, NOT THE ENTRIES (spec B1). This module is +# generic machinery over a policy-tagged path->policy map; it enrolls NO +# consumer itself. release-targets.md, open-tasks.md, and done-tasks.md are +# registered by their own later tasks (B-13/B-14/B-15) so this module never +# special-cases its first consumer. +# +# Library design invariants (mirrors every other _*lib.py, coding-standards.md): +# - Zero side effects at import time - no git calls, no file I/O on import. +# - Pure functions, testable with synthetic input; filesystem access will be +# isolated to `_hooklib.marker_fresh`, the one named reader this module +# calls (reused rather than re-implemented, per house rule). +# +# Public API (filled in task-by-task; see history for the T-01/T-04/T-05 +# slices): +# ProtectedPolicy enum: MARKER_GATED / HELPER_ONLY / +# APPEND_ONLY, str-valued +# ("marker-gated"/"helper-only"/ +# "append-only"). Constructing from an +# unknown value raises ValueError +# (internal error, not user input). +# REGISTRY -> dict[str, ProtectedPolicy] the live path->policy map. +# Starts EMPTY here; consumers are +# registered by their own tasks, never +# hardcoded in this module. +# lookup_policy(rel_path, registry=None) -> ProtectedPolicy | None +# the policy registered for a +# repo-relative path. Both the query +# path and every registry key are +# separator-normalized before +# comparison, or None if the path +# carries no policy. `registry` defaults +# to the module-level REGISTRY; a caller +# (a test, or a future flank) may pass a +# synthetic dict instead. Reached only +# through `classify_protected` +# (_protectedlib.py) in the wired path - +# never called directly with an ad hoc +# dict, which would drop the +# raw-and-realpath symlink coverage that +# dispatch provides. +# MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches +# the ADR-authoring gate's value by +# convention, not a shared import - see +# the constant's own comment for the +# five independent declarations. +# marker_name_for(rel_path) -> str a marker basename for a marker-gated +# path, encoding the path BELOW the +# repo's .codearbiter/ project-state +# root (not just the basename) so +# same-named files in different +# sub-directories never share a marker +# (e.g. ".codearbiter/release-targets.md" +# -> "release-targets-authoring", the +# pinned literal for the sole current +# consumer; "docs/release-targets.md" +# -> "docs__release-targets-authoring"). +# Always a single flat, traversal-safe +# filename; degenerate input (empty, +# None, a bare directory) never raises +# and never collides with a real path's +# derived name. +# marker_gated_write_admitted(rel_path, root, +# minutes=MARKER_FRESHNESS_MINUTES) -> bool +# True iff a fresh `-authoring` +# marker exists under +# .codearbiter/.markers/ for `rel_path`. +# False on an absent marker AND on a +# stale one - both cases delegate +# entirely to _hooklib.marker_fresh, so +# there is exactly one freshness +# implementation in the codebase. For +# `marker-gated` only; the flank wiring +# (pre-write.py, pre-edit.py, +# _bashguardlib.py per-class dispatch) +# is hook ID H-22 (H-21 is taken), +# built in later tasks of this slice - +# this function supplies the check the +# flanks will share, and does not +# itself call block()/remind(). + +from __future__ import annotations + +import os +from enum import Enum + +from _hooklib import marker_fresh +from _pathnorm import norm_path + + +class ProtectedPolicy(str, Enum): + """The three write-admission policies a protected-state registry entry + can carry (spec B1). str-valued so a member compares equal to its plain + string value (`ProtectedPolicy.MARKER_GATED == "marker-gated"`) and + round-trips through JSON without a second mapping layer. + + Interpolation caveat: use `.value`, not the member itself. `str(member)` / + an f-string / `%s` on a bare member yields `"ProtectedPolicy.MARKER_GATED"` + on Python 3.11+ (and the plain value on <=3.10) - the mixin does not make + those two forms agree across interpreters. Only `.value` (or the + already-proven `==` against a plain string) is the stable contract. + + Constructing an unknown value (`ProtectedPolicy("bogus")`) raises + ValueError via the stdlib Enum machinery - deliberately NOT caught here. + An unrecognized policy string reaching this constructor is a typo'd + registry entry - an internal programming error, not malformed user + input - so it is correct to raise rather than degrade.""" + + MARKER_GATED = "marker-gated" + HELPER_ONLY = "helper-only" + APPEND_ONLY = "append-only" + + +# The live protected-state registry: repo-relative path (separator- +# normalized) -> ProtectedPolicy. Deliberately EMPTY here - B1 (this module) +# ships the registry mechanism, not entries. release-targets.md/ +# open-tasks.md/done-tasks.md are added by their own later tasks +# (B-13/B-14/B-15), each a one-line entry, which is the whole point of +# building this as a registry instead of a per-file hook branch. +REGISTRY: dict[str, ProtectedPolicy] = {} + + +def lookup_policy(rel_path, registry=None): + """The ProtectedPolicy registered for `rel_path`, or None if it carries + no policy. Both `rel_path` and every registry key are separator- + normalized before comparison, so a Windows backslash path matches a + registry keyed with forward slashes (the same normalization every other + _*lib.py classifier applies via norm_path) AND a registry entry that was + itself typo'd with a backslash, a `./` prefix character, or a trailing + slash still matches rather than silently protecting nothing - a + malformed *key* degrades the same way a malformed query path does; only a + malformed *policy* (see ProtectedPolicy) is an internal error worth + raising on. + + `registry` defaults to the module-level REGISTRY; a test (or a future + caller) may pass a synthetic dict instead, which is what keeps this + function generic machinery rather than something wired to a specific + consumer set. This module must be reached through `classify_protected` + (`_protectedlib.py`), which runs every classifier against both the raw + and realpath-resolved forms of a path - a flank that calls + `lookup_policy` directly with its own ad hoc dict re-opens the symlink + alias this module does not itself guard against.""" + if registry is None: + registry = REGISTRY + normalized = norm_path(rel_path) + for key, policy in registry.items(): + if norm_path(key) == normalized: + return policy + return None + + +# The H-11 authoring-marker freshness window, matching the existing +# ADR-authoring gate's value by convention, NOT by a shared import: +# pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each +# independently hardcode `30` for the same marker shape, and this is a fifth, +# equally independent, declaration. Widening this constant does not widen +# theirs, and widening theirs does not widen this one - there is no single +# source of truth for the window's value across those five sites today. +MARKER_FRESHNESS_MINUTES = 30 + + +def marker_name_for(rel_path): + """The `<...>-authoring` marker basename for a marker-gated `rel_path` + (e.g. "release-targets.md" -> "release-targets-authoring") - the pattern + named in the sprint's pre-run dispositions for every future marker-gated + consumer, not just the first. + + Encodes the normalized relative path below the repo's single + `.codearbiter/` project-state root, not just the basename, so two + registry entries that merely share a filename in different + sub-directories (".codearbiter/release-targets.md" vs + ".codearbiter/nested/release-targets.md", or "a/x.md" vs "b/x.yml") mint + two distinct markers rather than one shared one - minting the marker for + one would otherwise admit a write to the other. A single leading + `.codearbiter` segment is dropped before encoding rather than treated as + disambiguating structure: every registry entry lives there by + construction (this module's whole domain), so keeping it out of the + encoding is what leaves the pinned literal for the sole current + marker-gated consumer unchanged (`.codearbiter/release-targets.md` -> + `release-targets-authoring`) while still telling apart two DIFFERENT + sub-directories. A literal `-` inside a directory segment is escaped + (doubled) before segments are joined with `-`, so a raw `-` in the + encoded name always marks a genuine directory boundary - this closes the + same-string collision a naive join would allow between, e.g., a + "prefix-name" directory and a "prefix"/"name" nested pair. `.`, `..`, and + empty segments are dropped before encoding (not merely trusted to + `os.path.basename`), so a stray `./` prefix, a trailing slash, or a + crafted `../` segment can never reach the returned name; the result is + always a single flat filename, never a path, so it stays contained under + .codearbiter/.markers/. Never raises: an empty/None `rel_path`, or one + with no real path segment (".", "..", "/"), has no directory or file to + encode and degrades to the fixed sentinel "-authoring" - a name no real + (non-empty) rel_path can ever produce, since a real segment always + contributes a non-empty stem. + + Residual (accepted): the escaping above closes hyphen/segment-boundary + ambiguity but is not a fully bijective encoding against adversarially + crafted underscore runs (e.g. a directory literally named "a_" holding + "_b.md" can alias "a" holding "__b.md"). The registry's entries are a + small, curated, human-authored set (spec B1/B2), not attacker-chosen + directory names, so this is judged out of proportion to close fully + here.""" + parts = [p for p in norm_path(rel_path).split("/") if p not in ("", ".", "..")] + if not parts: + return "-authoring" + if parts[0] == ".codearbiter" and len(parts) > 1: + parts = parts[1:] + stem = os.path.splitext(parts[-1])[0] + dir_tag = "-".join(p.replace("-", "--") for p in parts[:-1]) + encoded = f"{dir_tag}__{stem}" if dir_tag else stem + return f"{encoded}-authoring" + + +def marker_gated_write_admitted(rel_path, root, minutes=MARKER_FRESHNESS_MINUTES): + """True iff a Write/Edit/shell mutation of a `marker-gated` `rel_path` is + admitted: a `-authoring` marker exists under + .codearbiter/.markers/ and was touched within `minutes` (the H-11 + pattern). False on an absent marker AND on a stale one - both cases + delegate entirely to _hooklib.marker_fresh (the one filesystem reader + this module calls), so there is exactly one freshness implementation in + the codebase.""" + marker = os.path.join(root, ".codearbiter", ".markers", marker_name_for(rel_path)) + return marker_fresh(marker, minutes) diff --git a/plugins/ca/hooks/_protectedstatelib.py b/plugins/ca/hooks/_protectedstatelib.py new file mode 100644 index 00000000..82c87561 --- /dev/null +++ b/plugins/ca/hooks/_protectedstatelib.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# codeArbiter - protected-state registry: which project-state files are +# guarded, and under what POLICY (issue #564, workstream B1). +# +# Three consumers need materially different write semantics, so a flat +# marker-gated registry (the H-11 ADR-authoring pattern) is wrong for two of +# them. Each registry entry will carry exactly one of: +# +# marker-gated - a Write/Edit/shell mutation is admitted only under a +# fresh authoring marker (the H-11 pattern: a +# `-authoring` marker under .codearbiter/.markers/, +# minted immediately before the write and removed at lane +# exit). First consumer: release-targets.md. +# helper-only - Write, Edit, and shell naming the file are hard-blocked +# with NO marker path at all. The sanctioned helper's own +# Python file I/O is the only route - its argv never +# lexically names the file, so it is invisible to all three +# flanks by construction. Consumer: open-tasks.md via +# taskwrite.py. +# append-only - mutation is admitted only via the helper's append verb. +# Consumer: done-tasks.md via the archive verb. +# +# THE REGISTRY IS THE DELIVERABLE, NOT THE ENTRIES (spec B1). This module is +# generic machinery over a policy-tagged path->policy map; it enrolls NO +# consumer itself. release-targets.md, open-tasks.md, and done-tasks.md are +# registered by their own later tasks (B-13/B-14/B-15) so this module never +# special-cases its first consumer. +# +# Library design invariants (mirrors every other _*lib.py, coding-standards.md): +# - Zero side effects at import time - no git calls, no file I/O on import. +# - Pure functions, testable with synthetic input; filesystem access will be +# isolated to `_hooklib.marker_fresh`, the one named reader this module +# calls (reused rather than re-implemented, per house rule). +# +# Public API (filled in task-by-task; see history for the T-01/T-04/T-05 +# slices): +# ProtectedPolicy enum: MARKER_GATED / HELPER_ONLY / +# APPEND_ONLY, str-valued +# ("marker-gated"/"helper-only"/ +# "append-only"). Constructing from an +# unknown value raises ValueError +# (internal error, not user input). +# REGISTRY -> dict[str, ProtectedPolicy] the live path->policy map. +# Starts EMPTY here; consumers are +# registered by their own tasks, never +# hardcoded in this module. +# lookup_policy(rel_path, registry=None) -> ProtectedPolicy | None +# the policy registered for a +# repo-relative path. Both the query +# path and every registry key are +# separator-normalized before +# comparison, or None if the path +# carries no policy. `registry` defaults +# to the module-level REGISTRY; a caller +# (a test, or a future flank) may pass a +# synthetic dict instead. Reached only +# through `classify_protected` +# (_protectedlib.py) in the wired path - +# never called directly with an ad hoc +# dict, which would drop the +# raw-and-realpath symlink coverage that +# dispatch provides. +# MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches +# the ADR-authoring gate's value by +# convention, not a shared import - see +# the constant's own comment for the +# five independent declarations. +# marker_name_for(rel_path) -> str a marker basename for a marker-gated +# path, encoding the path BELOW the +# repo's .codearbiter/ project-state +# root (not just the basename) so +# same-named files in different +# sub-directories never share a marker +# (e.g. ".codearbiter/release-targets.md" +# -> "release-targets-authoring", the +# pinned literal for the sole current +# consumer; "docs/release-targets.md" +# -> "docs__release-targets-authoring"). +# Always a single flat, traversal-safe +# filename; degenerate input (empty, +# None, a bare directory) never raises +# and never collides with a real path's +# derived name. +# marker_gated_write_admitted(rel_path, root, +# minutes=MARKER_FRESHNESS_MINUTES) -> bool +# True iff a fresh `-authoring` +# marker exists under +# .codearbiter/.markers/ for `rel_path`. +# False on an absent marker AND on a +# stale one - both cases delegate +# entirely to _hooklib.marker_fresh, so +# there is exactly one freshness +# implementation in the codebase. For +# `marker-gated` only; the flank wiring +# (pre-write.py, pre-edit.py, +# _bashguardlib.py per-class dispatch) +# is hook ID H-22 (H-21 is taken), +# built in later tasks of this slice - +# this function supplies the check the +# flanks will share, and does not +# itself call block()/remind(). + +from __future__ import annotations + +import os +from enum import Enum + +from _hooklib import marker_fresh +from _pathnorm import norm_path + + +class ProtectedPolicy(str, Enum): + """The three write-admission policies a protected-state registry entry + can carry (spec B1). str-valued so a member compares equal to its plain + string value (`ProtectedPolicy.MARKER_GATED == "marker-gated"`) and + round-trips through JSON without a second mapping layer. + + Interpolation caveat: use `.value`, not the member itself. `str(member)` / + an f-string / `%s` on a bare member yields `"ProtectedPolicy.MARKER_GATED"` + on Python 3.11+ (and the plain value on <=3.10) - the mixin does not make + those two forms agree across interpreters. Only `.value` (or the + already-proven `==` against a plain string) is the stable contract. + + Constructing an unknown value (`ProtectedPolicy("bogus")`) raises + ValueError via the stdlib Enum machinery - deliberately NOT caught here. + An unrecognized policy string reaching this constructor is a typo'd + registry entry - an internal programming error, not malformed user + input - so it is correct to raise rather than degrade.""" + + MARKER_GATED = "marker-gated" + HELPER_ONLY = "helper-only" + APPEND_ONLY = "append-only" + + +# The live protected-state registry: repo-relative path (separator- +# normalized) -> ProtectedPolicy. Deliberately EMPTY here - B1 (this module) +# ships the registry mechanism, not entries. release-targets.md/ +# open-tasks.md/done-tasks.md are added by their own later tasks +# (B-13/B-14/B-15), each a one-line entry, which is the whole point of +# building this as a registry instead of a per-file hook branch. +REGISTRY: dict[str, ProtectedPolicy] = {} + + +def lookup_policy(rel_path, registry=None): + """The ProtectedPolicy registered for `rel_path`, or None if it carries + no policy. Both `rel_path` and every registry key are separator- + normalized before comparison, so a Windows backslash path matches a + registry keyed with forward slashes (the same normalization every other + _*lib.py classifier applies via norm_path) AND a registry entry that was + itself typo'd with a backslash, a `./` prefix character, or a trailing + slash still matches rather than silently protecting nothing - a + malformed *key* degrades the same way a malformed query path does; only a + malformed *policy* (see ProtectedPolicy) is an internal error worth + raising on. + + `registry` defaults to the module-level REGISTRY; a test (or a future + caller) may pass a synthetic dict instead, which is what keeps this + function generic machinery rather than something wired to a specific + consumer set. This module must be reached through `classify_protected` + (`_protectedlib.py`), which runs every classifier against both the raw + and realpath-resolved forms of a path - a flank that calls + `lookup_policy` directly with its own ad hoc dict re-opens the symlink + alias this module does not itself guard against.""" + if registry is None: + registry = REGISTRY + normalized = norm_path(rel_path) + for key, policy in registry.items(): + if norm_path(key) == normalized: + return policy + return None + + +# The H-11 authoring-marker freshness window, matching the existing +# ADR-authoring gate's value by convention, NOT by a shared import: +# pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each +# independently hardcode `30` for the same marker shape, and this is a fifth, +# equally independent, declaration. Widening this constant does not widen +# theirs, and widening theirs does not widen this one - there is no single +# source of truth for the window's value across those five sites today. +MARKER_FRESHNESS_MINUTES = 30 + + +def marker_name_for(rel_path): + """The `<...>-authoring` marker basename for a marker-gated `rel_path` + (e.g. "release-targets.md" -> "release-targets-authoring") - the pattern + named in the sprint's pre-run dispositions for every future marker-gated + consumer, not just the first. + + Encodes the normalized relative path below the repo's single + `.codearbiter/` project-state root, not just the basename, so two + registry entries that merely share a filename in different + sub-directories (".codearbiter/release-targets.md" vs + ".codearbiter/nested/release-targets.md", or "a/x.md" vs "b/x.yml") mint + two distinct markers rather than one shared one - minting the marker for + one would otherwise admit a write to the other. A single leading + `.codearbiter` segment is dropped before encoding rather than treated as + disambiguating structure: every registry entry lives there by + construction (this module's whole domain), so keeping it out of the + encoding is what leaves the pinned literal for the sole current + marker-gated consumer unchanged (`.codearbiter/release-targets.md` -> + `release-targets-authoring`) while still telling apart two DIFFERENT + sub-directories. A literal `-` inside a directory segment is escaped + (doubled) before segments are joined with `-`, so a raw `-` in the + encoded name always marks a genuine directory boundary - this closes the + same-string collision a naive join would allow between, e.g., a + "prefix-name" directory and a "prefix"/"name" nested pair. `.`, `..`, and + empty segments are dropped before encoding (not merely trusted to + `os.path.basename`), so a stray `./` prefix, a trailing slash, or a + crafted `../` segment can never reach the returned name; the result is + always a single flat filename, never a path, so it stays contained under + .codearbiter/.markers/. Never raises: an empty/None `rel_path`, or one + with no real path segment (".", "..", "/"), has no directory or file to + encode and degrades to the fixed sentinel "-authoring" - a name no real + (non-empty) rel_path can ever produce, since a real segment always + contributes a non-empty stem. + + Residual (accepted): the escaping above closes hyphen/segment-boundary + ambiguity but is not a fully bijective encoding against adversarially + crafted underscore runs (e.g. a directory literally named "a_" holding + "_b.md" can alias "a" holding "__b.md"). The registry's entries are a + small, curated, human-authored set (spec B1/B2), not attacker-chosen + directory names, so this is judged out of proportion to close fully + here.""" + parts = [p for p in norm_path(rel_path).split("/") if p not in ("", ".", "..")] + if not parts: + return "-authoring" + if parts[0] == ".codearbiter" and len(parts) > 1: + parts = parts[1:] + stem = os.path.splitext(parts[-1])[0] + dir_tag = "-".join(p.replace("-", "--") for p in parts[:-1]) + encoded = f"{dir_tag}__{stem}" if dir_tag else stem + return f"{encoded}-authoring" + + +def marker_gated_write_admitted(rel_path, root, minutes=MARKER_FRESHNESS_MINUTES): + """True iff a Write/Edit/shell mutation of a `marker-gated` `rel_path` is + admitted: a `-authoring` marker exists under + .codearbiter/.markers/ and was touched within `minutes` (the H-11 + pattern). False on an absent marker AND on a stale one - both cases + delegate entirely to _hooklib.marker_fresh (the one filesystem reader + this module calls), so there is exactly one freshness implementation in + the codebase.""" + marker = os.path.join(root, ".codearbiter", ".markers", marker_name_for(rel_path)) + return marker_fresh(marker, minutes) diff --git a/plugins/ca/hooks/tests/test_protectedstatelib.py b/plugins/ca/hooks/tests/test_protectedstatelib.py new file mode 100644 index 00000000..8fefb589 --- /dev/null +++ b/plugins/ca/hooks/tests/test_protectedstatelib.py @@ -0,0 +1,266 @@ +import os +import sys +import tempfile +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import _protectedstatelib # noqa: E402 +from _protectedstatelib import ( # noqa: E402 + MARKER_FRESHNESS_MINUTES, + ProtectedPolicy, + lookup_policy, + marker_gated_write_admitted, + marker_name_for, +) + + +def _touch(path, age_seconds=0): + """Create an empty file at `path`, optionally backdating its mtime by + `age_seconds` (the one filesystem-writing test helper, kept in one place + so every marker test backdates the same way).""" + with open(path, "w", encoding="utf-8") as f: + f.write("") + if age_seconds: + old = time.time() - age_seconds + os.utime(path, (old, old)) + + +class TestPolicyEnum(unittest.TestCase): + """B-01: the registry carries a per-entry policy enum with exactly the + three declared values, and an unknown policy string is an internal + programming error (a typo'd registry entry) that is correct to raise on, + not degrade from (coding-standards.md's "never raise on malformed user + input" rule is about INPUT reaching a hook, not a broken call site).""" + + def test_policy_enum(self): + self.assertEqual( + {p.value for p in ProtectedPolicy}, + {"marker-gated", "helper-only", "append-only"}, + ) + self.assertIs(ProtectedPolicy("marker-gated"), ProtectedPolicy.MARKER_GATED) + self.assertIs(ProtectedPolicy("helper-only"), ProtectedPolicy.HELPER_ONLY) + self.assertIs(ProtectedPolicy("append-only"), ProtectedPolicy.APPEND_ONLY) + with self.assertRaises(ValueError): + ProtectedPolicy("some-unrecognized-policy") + + def test_policy_enum_members_are_str_and_compare_equal_to_plain_values(self): + # Pins the `str` mixin itself (M4): dropping `class ProtectedPolicy(Enum)` + # (no str mixin) still satisfies `.value` checks, but `==` against a + # plain string and `isinstance(..., str)` both flip false without the + # mixin - these are the two assertions that actually depend on it. + self.assertEqual(ProtectedPolicy.MARKER_GATED, "marker-gated") + self.assertEqual(ProtectedPolicy.HELPER_ONLY, "helper-only") + self.assertEqual(ProtectedPolicy.APPEND_ONLY, "append-only") + self.assertIsInstance(ProtectedPolicy.MARKER_GATED, str) + + +class TestRegistryLookup(unittest.TestCase): + """B-01: a registered path returns its policy; an unregistered path + returns None. `registry` is an explicit parameter (defaulting to the + module REGISTRY) so this stays generic machinery testable with a + synthetic map, rather than something wired to a specific consumer set - + release-targets.md is NOT hardcoded into this module (its own later task + registers it; see the second test below).""" + + def test_registry_lookup(self): + registry = {".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED} + self.assertEqual( + lookup_policy(".codearbiter/release-targets.md", registry), + ProtectedPolicy.MARKER_GATED, + ) + self.assertIsNone(lookup_policy(".codearbiter/open-tasks.md", registry)) + + def test_registry_lookup_normalizes_backslash_paths(self): + registry = {".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED} + self.assertEqual( + lookup_policy(".codearbiter\\release-targets.md", registry), + ProtectedPolicy.MARKER_GATED, + ) + + def test_registry_lookup_normalizes_backslash_registry_keys(self): + # M6: a typo'd registry KEY (backslash-separated) must not silently + # protect nothing - the same normalization applies to keys as to the + # query path. + registry = {".codearbiter\\release-targets.md": ProtectedPolicy.MARKER_GATED} + self.assertEqual( + lookup_policy(".codearbiter/release-targets.md", registry), + ProtectedPolicy.MARKER_GATED, + ) + + def test_registry_lookup_default_registry_has_no_hardcoded_consumers(self): + # B1 ships the registry mechanism, not entries - release-targets.md + # is registered by its OWN later task (B-13), never by this module. + self.assertIsNone(lookup_policy(".codearbiter/release-targets.md")) + + def test_registry_lookup_default_registry_is_empty(self): + # H1: a direct positive fact about the production registry itself, + # not just a negative lookup - catches a mutant that seeds REGISTRY + # with a bogus entry, which the negative test above cannot detect + # (it would only notice a seeded entry that happens to collide with + # this one literal path). + self.assertEqual(_protectedstatelib.REGISTRY, {}) + + def test_registry_lookup_reads_the_default_module_registry(self): + # H1: the three "positive lookup" tests above all inject a synthetic + # registry, so none of them proves lookup_policy's default path + # (`registry is None -> registry = REGISTRY`) actually reads the + # module-level REGISTRY at all - a mutant hardcoding `registry = {}` + # passed every existing test. Patch REGISTRY itself and call + # lookup_policy with NO registry argument, through the real default + # path, then restore. + original = _protectedstatelib.REGISTRY + try: + _protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + self.assertEqual( + lookup_policy(".codearbiter/release-targets.md"), + ProtectedPolicy.MARKER_GATED, + ) + finally: + _protectedstatelib.REGISTRY = original + + +class TestMarkerGatedWrite(unittest.TestCase): + """B-02: for the `marker-gated` policy, a Write/Edit/shell mutation is + admitted only under a fresh authoring marker (the H-11 pattern) - a fresh + marker admits, an absent marker blocks.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = self._tmp.name + self.markers_dir = os.path.join(self.root, ".codearbiter", ".markers") + os.makedirs(self.markers_dir) + + def tearDown(self): + self._tmp.cleanup() + + def test_marker_gated_write_fresh_marker_admits(self): + _touch(os.path.join(self.markers_dir, "release-targets-authoring")) + self.assertTrue( + marker_gated_write_admitted(".codearbiter/release-targets.md", self.root) + ) + + def test_marker_gated_write_absent_marker_blocks(self): + self.assertFalse( + marker_gated_write_admitted(".codearbiter/release-targets.md", self.root) + ) + + def test_marker_gated_write_derives_stem_authoring_marker_name(self): + self.assertEqual( + marker_name_for(".codearbiter/release-targets.md"), + "release-targets-authoring", + ) + + def test_marker_gated_write_honors_custom_minutes_parameter(self): + # L8: `minutes` is a real parameter, not dead - a 2-minute-old marker + # is stale under a 1-minute window and fresh under a 5-minute one. + _touch( + os.path.join(self.markers_dir, "release-targets-authoring"), + age_seconds=120, + ) + self.assertFalse( + marker_gated_write_admitted( + ".codearbiter/release-targets.md", self.root, minutes=1 + ) + ) + self.assertTrue( + marker_gated_write_admitted( + ".codearbiter/release-targets.md", self.root, minutes=5 + ) + ) + + +class TestMarkerStale(unittest.TestCase): + """B-10: a marker older than the freshness window does not admit a + write. Exercises the boundary in both directions so the test is not + vacuous against an implementation that ignores mtime entirely.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = self._tmp.name + self.markers_dir = os.path.join(self.root, ".codearbiter", ".markers") + os.makedirs(self.markers_dir) + self.marker_name = "release-targets-authoring" + + def tearDown(self): + self._tmp.cleanup() + + def test_marker_stale_blocks(self): + _touch( + os.path.join(self.markers_dir, self.marker_name), + age_seconds=MARKER_FRESHNESS_MINUTES * 60 + 60, + ) + self.assertFalse( + marker_gated_write_admitted(".codearbiter/release-targets.md", self.root) + ) + + def test_marker_stale_just_inside_window_still_admits(self): + _touch( + os.path.join(self.markers_dir, self.marker_name), + age_seconds=MARKER_FRESHNESS_MINUTES * 60 - 60, + ) + self.assertTrue( + marker_gated_write_admitted(".codearbiter/release-targets.md", self.root) + ) + + +class TestFreshnessWindow(unittest.TestCase): + """H2: the freshness window's VALUE is part of the contract, not just its + boundary arithmetic - both stale-window tests above compute their ages + from MARKER_FRESHNESS_MINUTES itself, so they pin the boundary logic but + would pass unchanged if the constant were widened (or narrowed) to any + other value. Pin the value directly, matching the H-11 ADR-authoring + window it is designed to mirror.""" + + def test_marker_freshness_minutes_is_30(self): + self.assertEqual(MARKER_FRESHNESS_MINUTES, 30) + + +class TestMarkerNameFor(unittest.TestCase): + """B-02/M5/M7: marker_name_for's derivation - collision-freedom across + sub-directories, backslash normalization, and non-raising degenerate + input.""" + + def test_marker_name_for_normalizes_backslash_paths(self): + # M7: lookup_policy has a backslash-normalization test; marker_name_for + # had no counterpart, so a mutant swapping norm_path(rel_path) for a + # bare `rel_path or ""` survived on both platforms. + self.assertEqual( + marker_name_for(".codearbiter\\release-targets.md"), + marker_name_for(".codearbiter/release-targets.md"), + ) + + def test_marker_name_for_is_collision_free_across_sub_directories(self): + # M5: two registry entries that merely share a filename in different + # directories must mint two DIFFERENT markers - minting the marker + # for one would otherwise admit a write to the other. + self.assertNotEqual( + marker_name_for(".codearbiter/release-targets.md"), + marker_name_for(".codearbiter/nested/release-targets.md"), + ) + self.assertNotEqual( + marker_name_for("a/x.md"), + marker_name_for("b/x.yml"), + ) + + def test_marker_name_for_degenerate_inputs_never_raise_and_stay_flat(self): + # M5: "", None, and a bare directory must not silently crash and must + # stay a single, traversal-safe basename (no separators reaching the + # returned name). + for degenerate in ("", None, "docs/", ".", ".."): + name = marker_name_for(degenerate) + self.assertIsInstance(name, str) + self.assertNotIn("/", name) + self.assertNotIn("\\", name) + self.assertNotIn("..", name) + self.assertEqual(marker_name_for(""), "-authoring") + self.assertEqual(marker_name_for(None), "-authoring") + + def test_marker_name_for_degenerate_input_never_collides_with_a_real_path(self): + self.assertNotEqual(marker_name_for(""), marker_name_for("release-targets.md")) + + +if __name__ == "__main__": + unittest.main() From 56387eee0164740a20c346e41fbb8c6f959e05b2 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Fri, 31 Jul 2026 03:27:51 -0400 Subject: [PATCH 03/66] feat(hooks): wire the protected-state registry into all three flanks as H-22 (#564) classify_protected grows a fifth "state" class rather than the flanks doing a parallel registry lookup. _protectedlib documents why: #528/#529 deadlocked because two independent class checks disagreed on membership, and #162 symlink laundering is closed inside the classifier, which runs every classifier against both the raw and realpath-resolved forms. A parallel lookup reintroduces the first and ships without the second. Two independent reviews ran against the first cut. A security pass returned PASS with no CRITICAL or HIGH; an adversarial pass ran 37 mutants, killed 28, and graded no HIGH. Twelve findings between them are remediated here, each with a mutation proving the new test kills it. Two were silent fail-opens that only become reachable once consumers enrol, and neither enrolment task re-runs these reviews: The flanks disagreed on case. The shell side compiled with re.I while the registry lookup compared case-sensitively, breaking a rule stated verbatim in the same file from #528. On macOS a Write to a differently-cased spelling missed the registry and landed on the real file, invisible here because nt.realpath folds case and posixpath.realpath does not. Resolved as global case-insensitivity: case-sensitivity varies by platform and by volume, and realpath cannot fold case for a path that does not yet exist, which is exactly a Write creating a protected file for the first time. A fixed rule both flanks apply without consulting the filesystem only widens what is protected. Symlink coverage was inverted. The raw leg of the two-form lookup never fired, because hosts always send absolute paths while registry keys are repo-relative, so the class rested entirely on repo_rel. With the protected path symlinked, the absolute spelling, the only one a host sends, was admitted. raw_repo_rel() gives the raw leg a lexical repo-relative form so both legs are meaningful. The shell flank also gained the git checkout/restore leg H-05 has carried since #335, the sponge/ln/install/patch/shred verbs, and an interpreter leg closing python -c open-for-write, which contradicted helper-only's own premise that the sanctioned helper's Python file I/O is the only route. touch stays excluded. Both reviewers' positions are recorded in security-controls.md, which gains a purely additive H-22 section declaring the case decision and the accepted bare-basename over-match residuals. Undeclared and accepted are different things. Refs: #564 --- .../portable-release-and-protected-state.md | 24 +- .codearbiter/security-controls.md | 104 +++ .codearbiter/sprint-log.md | 1 + .github/scripts/test_hooklib.py | 78 ++ core/pysrc/_bashguardlib.py | 211 ++++++ core/pysrc/_pathnorm.py | 30 + core/pysrc/_protectedlib.py | 118 ++- core/pysrc/_protectedstatelib.py | 146 +++- core/pysrc/pre-edit.py | 33 +- core/pysrc/pre-write.py | 32 + plugins/ca-codex/hooks/_bashguardlib.py | 211 ++++++ plugins/ca-codex/hooks/_pathnorm.py | 30 + plugins/ca-codex/hooks/_protectedlib.py | 118 ++- plugins/ca-codex/hooks/_protectedstatelib.py | 146 +++- plugins/ca-codex/hooks/pre-edit.py | 33 +- plugins/ca-codex/hooks/pre-write.py | 32 + plugins/ca-pi/hooks/_bashguardlib.py | 211 ++++++ plugins/ca-pi/hooks/_pathnorm.py | 30 + plugins/ca-pi/hooks/_protectedlib.py | 118 ++- plugins/ca-pi/hooks/_protectedstatelib.py | 146 +++- plugins/ca-pi/hooks/pre-edit.py | 33 +- plugins/ca-pi/hooks/pre-write.py | 32 + plugins/ca/hooks/_bashguardlib.py | 211 ++++++ plugins/ca/hooks/_pathnorm.py | 30 + plugins/ca/hooks/_protectedlib.py | 118 ++- plugins/ca/hooks/_protectedstatelib.py | 146 +++- plugins/ca/hooks/pre-edit.py | 33 +- plugins/ca/hooks/pre-write.py | 32 + plugins/ca/hooks/tests/test_pre_edit.py | 157 ++++ plugins/ca/hooks/tests/test_pre_write.py | 163 +++++ .../ca/hooks/tests/test_protectedstatelib.py | 689 ++++++++++++++++++ 31 files changed, 3412 insertions(+), 84 deletions(-) diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md index 03e4ce0d..a859766f 100644 --- a/.codearbiter/plans/portable-release-and-protected-state.md +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -114,13 +114,13 @@ Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, sh | id | path(s) | verification | maps-to | covers | depends | status | |---|---|---|---|---|---|---| -| T-05a | `core/pysrc/_protectedlib.py` | `python .github/scripts/test_hook_guards.py` — `classify_protected` returns a `"state"` class for a registered path, evaluated **after** the existing four; return contract stays a set of strings so its four consumers see no change | classifier extension | B-01 | T-05 | PENDING | -| T-05b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_no_legacy_overlap` — **no** registered path classifies into any legacy class; overlap fails loudly as a configuration error rather than resolving by precedence | overlap guard | B-01 | T-05a | PENDING | -| T-06 | `core/pysrc/pre-write.py` | `python .github/scripts/test_hook_guards.py` — one generic `"state"` branch resolves the entry's policy from the registry and applies it; no second lookup | pre-write flank | B-02, B-05 | T-05b | PENDING | -| T-07 | `core/pysrc/pre-edit.py` | `python .github/scripts/test_hook_guards.py` — same generic branch; `helper-only` blocks **unconditionally**, no marker path | pre-edit flank | B-03, B-05 | T-06 | PENDING | -| T-08 | `core/pysrc/_bashguardlib.py` | `python .github/scripts/test_hook_guards.py` — `_state_write_res(basename) -> (redirect_re, write_re)` template mirroring `CONTEXT_REDIRECT_RE`/`CONTEXT_WRITE_RE` (lines 355-359), compiled once at import from the **code-constant** registry, per entry rather than one alternation | shell flank | B-04, B-05 | T-07 | PENDING | -| T-08a | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_marker_touch_allowed` — `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; `GATE_MARKER_NAMES` gains a comment stating it enumerates **block-to-allow** markers while friction markers stay touchable by design | minting fence | B-04 | T-08 | PENDING | -| T-08b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_verb_in_description_residual` — documents that `taskwrite add -- "remember to tee open-tasks.md"` false-blocks; pins the **passing** B-08 form and records the residual rather than chasing it with smarter parsing | lexical residual | B-08 | T-08a | PENDING | +| T-05a | `core/pysrc/_protectedlib.py` | `python .github/scripts/test_hooklib.py -k ClassifyProtectedStateTest` — `classify_protected` returns `"state"` for a registered path; return contract stays a set of strings so its four consumers see no change | classifier extension | B-01 | T-05 | ACCEPTED | +| T-05b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_no_legacy_overlap` — **no** registered path classifies into any legacy class; overlap fails loudly as a configuration error rather than resolving by precedence | overlap guard | B-01 | T-05a | ACCEPTED | +| T-06 | `core/pysrc/pre-write.py` | `SUITE -p "test_pre_write.py" -k TestH22ProtectedState` — one generic `"state"` branch resolves the entry's policy from the registry and applies it; no second lookup | pre-write flank | B-02, B-05 | T-05b | ACCEPTED | +| T-07 | `core/pysrc/pre-edit.py` | `SUITE -p "test_pre_edit.py" -k TestH22ProtectedState` — same generic branch; `helper-only` blocks **unconditionally**, no marker path | pre-edit flank | B-03, B-05 | T-06 | ACCEPTED | +| T-08 | `core/pysrc/_bashguardlib.py` | `SUITE -k TestStateShell` — `_state_write_res(basename) -> (redirect_re, write_re)` compiled once at import from the **code-constant** registry, per entry rather than one alternation. `TestStateShellWiring` must drive `run_guards()` itself: every other shell test calls `_check_h22_state` directly and would pass with the wiring deleted | shell flank | B-04, B-05 | T-07 | ACCEPTED | +| T-08a | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_marker_touch_allowed` — `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; `GATE_MARKER_NAMES` gains a comment stating it enumerates **block-to-allow** markers while friction markers stay touchable by design | minting fence | B-04 | T-08 | ACCEPTED | +| T-08b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_verb_in_description_residual` — documents that `taskwrite add -- "remember to tee open-tasks.md"` false-blocks; pins the **passing** B-08 form and records the residual rather than chasing it with smarter parsing | lexical residual | B-08 | T-08a | ACCEPTED | **Rulings encoded above, with the reasoning that produced them:** @@ -142,11 +142,11 @@ Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, sh an authoring marker fakes nothing and is self-mintable by design (ADR-0010). The risk runs opposite to intuition — a future generic "hardening" over every registered marker would brick every minting lane while stopping no non-cooperative agent. T-08a is the fence against that. -| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_git_add` — `git add open-tasks.md` passes | git-verb non-regression | B-07 | T-08 | PENDING | -| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_filename_as_data` — helper call with filename in description passes | argv-data non-regression | B-08 | T-08 | PENDING | -| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_flank_shell_writes_block` — `tee` and `>>` both block | shell-write blocking | B-09 | T-08 | PENDING | -| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_helper_path_survives` — `taskwrite.py` invocation admitted with enrolment live | circularity proof | B-12 | T-08 | PENDING | -| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_append_only_policy` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | PENDING | +| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_git_add_passes` — `git add open-tasks.md` passes. Load-bearing: `commit-gate` Phase 7 runs exactly that on every retained board flip, so a git verb in the list makes commit-gate block itself | git-verb non-regression | B-07 | T-08 | PENDING | +| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_filename_as_helper_argv_data_passes` — helper call with the filename in its description passes | argv-data non-regression | B-08 | T-08 | PENDING | +| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_tee_blocks_unconditionally` and `-k test_append_redirect_blocks_unconditionally` | shell-write blocking | B-09 | T-08 | PENDING | +| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_taskwrite_invocation_passes_with_enrolment_live` — the real `core/surface/commands/task.md` invocation shape. **Not mutation-killable by construction** — the command names no registered basename, which is the property being proved; assert it against the generated invocation rather than a hand-copy so it notices drift | circularity proof | B-12 | T-08 | PENDING | +| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k TestStateShellAppendOnly` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | PENDING | | T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | PENDING | | T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | PENDING | | T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | PENDING | diff --git a/.codearbiter/security-controls.md b/.codearbiter/security-controls.md index 86495b37..3fb673a9 100644 --- a/.codearbiter/security-controls.md +++ b/.codearbiter/security-controls.md @@ -560,6 +560,110 @@ vague "if the threat model expands to untrusted agents". Any ONE reopens it: --- +## Protected-state registry (H-22) + +`_protectedstatelib.py` (B1/#564) is a generic path->policy registry — `marker- +gated`, `helper-only`, or `append-only` — enforced by a fifth `classify_protected` +class (`"state"`) across all three flanks: `pre-write.py`/`pre-edit.py` (Write/ +Edit) and `_bashguardlib.py`'s H-22 check (shell). The registry itself ships +EMPTY as of this slice; `release-targets.md`, `open-tasks.md`, and +`done-tasks.md` are enrolled by their own later tasks (T-33/T-65/T-66). The +residuals below are declared now, ahead of that enrolment, so they are visible +from day one rather than discovered live. + +**Case and canonicalization are deliberately GLOBAL, not host-filesystem- +dependent.** Both flanks — `_protectedstatelib.lookup_policy`'s registry +comparison and `_bashguardlib._state_write_res`'s shell regexes — treat a +registered path case-INSENSITIVELY and tolerate a `./` prefix, a trailing +slash, a doubled slash, and a leading/trailing space. Matching the *host* +filesystem's own case-sensitivity was considered and rejected: it varies by +platform AND by volume on the same platform (Windows/NTFS and default macOS/ +APFS are case-preserving-but-insensitive; Linux ext4 and non-default macOS +volumes are case-sensitive), and `os.path.realpath` does not reliably fold +case for a path that does not yet exist on disk — exactly the case of a Write +that creates a protected-state file for the first time. A fixed, +case-insensitive rule that both flanks can apply without inspecting the +filesystem was judged safer: it only WIDENS what H-22 protects (a same- +directory file whose name differs from a registered path only by case is also +treated as protected), never narrows it. + +**The bare-basename shell anchor over-matches by design, and the known false +blocks are accepted.** H-22's shell flank matches a registered file's bare +basename with NO directory prefix requirement (unlike `CONTEXT_MD`/`GATE_MARKER`) +— this is forced by spec B1 (`taskwrite add -- "fix open-tasks.md schema"` and a +bare `tee open-tasks.md` run from inside `.codearbiter/` both need to be +distinguishable/catchable with no directory prefix in the command text). The +cost, verified and accepted (the sanctioned bypass for a false block is +`/ca:override`): + +- a longer filename that happens to END with a registered basename still + matches — `> my-open-tasks.md` blocks even though it targets a different + file. A right-edge lookahead (mirroring `DECISION_LOG_SHELL_RE`, #528) closes + the mirror-image case (`rm .codearbiter/open-tasks.md.bak` no longer + matches), but there is no equivalent left-edge anchor: the bare-basename + design has no directory context available to distinguish a genuine + no-prefix spelling from a longer name's suffix; +- a same-named file in an UNRELATED directory still matches — + `rm node_modules/somepkg/open-tasks.md`, `tee tests/fixtures/open-tasks.md` + — the direct, load-bearing consequence of the bare-basename anchor itself + (see `_bashguardlib.py`'s `_state_write_res` module comment for the full + B-07/B-08/T-08b rationale this over-match is forced by); +- a verb that only READS the protected file, then writes elsewhere, still + matches — `cp .codearbiter/open-tasks.md /tmp/backup` — the same + "ambiguity resolves CLOSED" stance the H-05 audit-log guard already applies + to the identical `cp overrides.log backup` shape. + +**H-22's write-verb list is wider than the H-05/H-11/H-18 baseline it was +copied from, and the extra verbs are declared, not merely implicit.** Past the +shared baseline (`rm|del|mv|cp|copy|dd|tee|sed|truncate|ni|New-Item| +Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content|Out-File| +Add-Content`), H-22 additionally blocks `sponge` (already in H-05's +`LOG_DESTROY_RE`), `ln`, `install`, `patch`, and `shred`. `ln`/`install` are +both real, if less common, English/shell words — `npm install` and `pip +install` are common phrases that could, in principle, sit lexically near a +protected basename in an unrelated command — accepted under the same +"ambiguity resolves CLOSED" stance applied throughout this file. `git +checkout`/`git restore` (a tracked worktree file can be rewritten through git +itself, bypassing every filesystem verb) are covered by a SEPARATE regex leg, +mirroring H-05's `LOG_GIT_RESTORE_RE` (#335) — deliberately not folded into +the general verb list, so it cannot also catch `git add` (commit-gate Phase 7 +runs `git add open-tasks.md` on every retained board flip, and must never trip +H-22). An arbitrary interpreter one-liner +(`python -c "open('open-tasks.md','w')..."`) is covered by a third leg +mirroring `GATE_MARKER_INTERP_RE` (#237) — the `helper-only` policy's whole +premise is that the sanctioned helper's own Python file I/O is the only +legitimate route, so an interpreter one-liner reusing that exact route while +naming the file lexically must be caught the same way #237 already catches it +for gate markers. + +**`touch` is deliberately excluded — two positions are recorded, not one.** +security-reviewer traced every `.codearbiter`-state mtime consumer and found +none feeds an admission decision: `marker_gated_write_admitted` stats the +*marker's* mtime, never the protected file's, so even `touch -t` back-dating a +protected file admits nothing on its own. The adversarial pass counters that +for the `helper-only` policy specifically, *creation itself* is the violation +H-11's own precedent guards against — `touch`ing an absent board would create +an empty one outside the sanctioned helper, and `DECISIONS_WRITE_RE` (H-11's +own shell flank) does include `touch` for exactly that reason. The exclusion +stands: the admission-analysis argument is decisive for what this specific +guard can observe, and including `touch` would false-block a legitimate +description like `taskwrite add -- "touch up open-tasks.md wording"`. Both +positions are recorded here so a future reader sees a considered decision, not +an oversight. + +**Scanning the raw `cmd` (not the heredoc-stripped `git_view`) is a known, +consistent residual (LOW-5).** H-22's shell check, like H-05/H-11/H-18, scans +the RAW command rather than plumbing through the `git_view`/ +`heredoc_shell_fallback` machinery H-19's gate-marker check uses. A heredoc +body fed to a non-shell consumer that merely QUOTES a protected filename in +prose (e.g. a PR/issue body describing this very control) could, in principle, +false-trip H-22 the same way it could H-05/H-11/H-18 before H-19 grew that +extra plumbing for its own DOTALL-crossing concern. Left as-is for +consistency with the three guards it was modeled on; revisited only if it +proves to cost more false blocks in practice than the extra plumbing is worth. + +--- + ## Published tag immutability Four installable tag series are published from this repository: `v*` (ca), diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index bfc4204d..85f6cf0e 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -1214,3 +1214,4 @@ Started 2026-07-20T01:40:46-04:00. Append-only. SMARTS-scored auto-decisions; - [2026-07-31] CORRECTION portable-release-and-protected-state second ledger hole | the completion bar exposed a coverage gap of the same class as A-6.0: #563's acceptance carried "a consumer repo with one artifact can run /ca:release end to end" as a prose checkbox, but neither spec held it as a numbered criterion, so no task proved the thing the entire campaign exists to deliver | this is the failure mode recorded in project memory as dev-repo-state-masks-consumer-bugs — verifying against this repo's hand-built .codearbiter/ state hides consumer-facing bugs | FIX: added A-6.6 (clean consumer repo, single package.json, one CHANGELOG.md, one v* tag, codeArbiter installed, ZERO files from this repository, running through target resolution, window derivation, bump classification and changelog rolling) and A-6.7 (this repo still composes the same version the pre-change lane would derive, tag discarded not published), covered by new tasks T-73..T-76 as a Step 8 completion proof | ledger now 72 criteria over 86 tasks | strength: strong | confidence: high. - [2026-07-31] PROXY-RULING portable-release-and-protected-state T-06..T-08 flank wiring | answered by the standing adversarial advisor under the maintainer-proxy authorization; four design questions resolved so the flank wiring does not stall, two of them by rejecting my framing | Q1 policy dispatch: CHOSEN extend `_hooklib.classify_protected` to return a new `"state"` class, with policy semantics resolved from the registry inside a single generic flank branch — NOT a parallel lookup; grounded in `_protectedlib.py:13-19`, which documents the #528/#529 failure where independent class checks on decision-log.md deadlocked because set membership was uncoordinated, and in #162 symlink laundering being closed INSIDE classify_protected (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms), so a parallel lookup would ship without symlink resolution and a symlink alias would write through the guard on day one; SMARTS strong, Scalable/Maintainable/Reliable/Testable/Securable all Strong for extend vs Weak for parallel; riders: `"state"` evaluates after the existing four classes, a registry-load test asserts NO registered path classifies into any legacy class (overlap is a loud configuration error, not a precedence puzzle), and classify_protected's return contract stays a set of strings so its four existing consumers see zero change | Q2 helper-only absoluteness: CHOSEN unconditional block, confirmed safe against the full producer inventory (taskwrite.py and init-codearbiter.py use Python file I/O; boardsync.py is write-free by its own header; session-start.py reads; every skill lane routes through the task command after T-54/T-55) — but the advisor surfaced a case I had not asked about: a MERGE OR REBASE CONFLICT IN open-tasks.md ITSELF, where the resolver must edit the working-tree file and taskwrite has no resolve verb, so an absolute block turns every board conflict into a stop; ruling: block STANDS, conflict handled as a named residual routed through logged `/ca:override`, with an ADR reopen condition — if gate-events.log shows board-conflict overrides recurring, the fix is a deterministic `taskwrite resolve` verb, never a guard exception; a conflict-marker content predicate ("admit Edit when the file contains <<<<<<<") was rejected as converting file content into an authorization signal, the same launderable shape the T-55 file-absent exemption lost on | Q3 shell-flank parameterization: PREMISE REJECTED — the registry must be CODE CONSTANTS and never disk-loaded, because a disk registry would let a consumer repo un-protect open-tasks.md by editing a file; the zero-side-effects-at-import rule prohibits file I/O and git, never regex compilation from module constants, with `_scopelib.py:109-117` as direct precedent (default glob tuples precompiled at load); given that, CHOSEN per-entry regex pairs from a template `_state_write_res(basename) -> (redirect_re, write_re)` mirroring CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE shapes at `_bashguardlib.py:355-359`, compiled once at import, rather than one generic alternation whose single failure smears across all entries; verified mechanically against both pinned non-regressions (`git add open-tasks.md` passes — git is not a verb and no redirect precedes the name; the filename-in-description helper call passes — python3/taskwrite/add are not verbs; `tee` and `>>` both block); ONE RESIDUAL to document with a test rather than fix: a description containing a write verb (`taskwrite add -- "remember to tee open-tasks.md"`) false-blocks, which is the same accepted lexical residual every _bashguardlib flank carries — name it in the ADR, pin the passing form, do not chase it with smarter parsing | Q4 marker carve-out: FRAMING REJECTED — there is no exclusion to extend; `_protectedlib.py:109-113` blocks GATE_MARKER_NAMES (security-gate-passed, migration-gate-passed) only, and adr-authoring-active was never enumerated into it, because the membership criterion is whether a marker converts a BLOCK into an ALLOW; a forged gate-pass fakes a security verdict, a forged authoring marker fakes nothing since it is audit friction, self-mintable by design per ADR-0010; CHOSEN change nothing, add no generic marker handling, and instead encode the CRITERION rather than the membership — one comment on GATE_MARKER_NAMES stating that the list enumerates block-to-allow markers while friction/authoring markers stay shell-touchable by design, plus one pinning test asserting `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; the accidental-widening risk runs OPPOSITE to my question — the danger is a future maintainer "hardening" the blocklist generically over every registered marker, which would brick every minting lane while stopping no non-cooperative agent, and the pinning test is the fence against that tidy-up | confidence: high — all four grounded in cited code, two correcting my premise; none touches a non-delegable hard gate. - [2026-07-31] REVIEW portable-release-and-protected-state B1 slice (T-01..T-05) | Opus adversarial pass per the maintainer's standard; 13-mutant campaign against the 9 new tests, 7 survivors, 2 HIGH | verdict: no live defect and no security hole — traversal safety, freshness delegation, genericity and zero-side-effects-at-import all survived attack, the latter verified under sys.addaudithook rather than assumed — but TEST ADEQUACY failed against the standing "every new test must die to a mutant" directive | HIGH-1: the default-registry code path has no fail-detecting test; mutating `registry = REGISTRY` to `registry = {}` passes all 9, because the only default-path test is a NEGATIVE assertion against one literal path and all three positive tests inject a synthetic registry — meaning nothing proves the production path works, and once consumers enrol at B-13/14/15 the entire guard could be a silent no-op with CI green | HIGH-2: the freshness window is a tautology in both directions; MARKER_FRESHNESS_MINUTES mutated to 1000 or 3 passes all 9, because both freshness tests compute their ages FROM the implementation constant, pinning boundary logic but not value — a 33x widening of a security window would ship green; compounded by a comment claiming the constant is "reused verbatim (not re-declared)" when 30 is in fact hardcoded in five independent places with no import relationship | ORCHESTRATOR ERROR, recorded deliberately: MEDIUM-3 established that my own earlier "fix" was wrong. I removed `REGISTRY: dict[str, ProtectedPolicy]` believing a PEP 585 builtin generic is evaluated at import and raises on Python 3.8, and reported that to the maintainer as a confirmed defect CI could not catch. The module carries `from __future__ import annotations` at line 84, which under PEP 563 leaves VARIABLE annotations unevaluated as well — verified by probe: the annotation with an undefined name raises nothing. The annotation was always safe on 3.8. Worse than the unnecessary change, the comment I left asserted "Every sibling _*lib.py declares module dicts bare for the same reason", a false rule that would have propagated into the next four modules of this campaign. Remediation restores the annotation and deletes the comment | remaining MEDIUMs sent for fix rather than filing, because all four propagate into the next four modules: a typo'd registry KEY fails open and silent (norm_path applied to the query, never the keys) which directly contradicts the module's own stated doctrine that malformed input must fail loud; the `str` mixin is entirely untested and its docstring's argv claim is false on 3.11+ where interpolation yields the class-qualified name and varies by interpreter version; `marker_name_for` is basename-only so two registered files sharing a stem share one authoring marker; and its norm_path call is untested | LOWs at discretion: dead `minutes` parameter, and the call-site constraint (module must be reached through classify_protected, never called directly by a flank) documented only in this log | confidence: high — every finding reproduced by executed mutation, not asserted. +- [2026-07-31] SECURITY-GATE portable-release-and-protected-state H-22 flank wiring | security-reviewer verdict PASS, 0 CRITICAL 0 HIGH — the tripwire disposition therefore holds and autonomous execution continues without a maintainer stop | cleared by execution rather than by reading: the registry-is-code-constant property genuinely holds (REGISTRY assigned once as a literal, no json.load/open/env read anywhere in core/pysrc); the deferred import is necessary rather than stylistic and FAILS CLOSED (at call time it is a sys.modules hit since all three flanks already import the module at scope; a raise propagates into the except backstop and becomes block H-00 exit 2); every traced fail direction is correct (registry raise, unreadable marker dir, unresolvable root, cross-drive path, resolve returning None — all closed; genuinely-outside-repo correctly allows); and both load-bearing non-regressions are proved rather than accidental, with the regex-level cause pinned independently | the `touch` exclusion was CLEARED with evidence rather than accepted on precedent: touch cannot truncate on any supported platform, the PowerShell verbs that CAN truncate are all blocked, and every mtime consumer under .codearbiter/ was traced — the statusline key fails toward MORE work not less, staleness_warning does not key on registry paths, and marker_gated_write_admitted stats the MARKER not the protected file, so even `touch -t` back-dating cannot admit anything; the H-11 precedent that DOES include touch is principled (there, creation itself is the violation; here the helper creates the file legitimately) | THREE MEDIUMS, all latent while the registry is empty and all going live at B-13/B-14/B-15 — and those enrolment tasks will not re-run this review, which is the reviewer's argument for closing them inside this sprint rather than filing | M-1: lookup_policy matches by exact equality after separator normalization only, while its own docstring claims a `./` prefix or trailing slash still matches; measured, `./.codearbiter/open-tasks.md`, a trailing slash, a doubled slash and a leading space all MISS, so a mis-keyed entry protects nothing while LOOKING protected in manual testing because the shell flank keys off the bare basename and still fires | M-2, the more serious: the raw leg of the two-form lookup is INERT in the wired path because hosts always send absolute paths, so the state class rests entirely on repo_rel — which inverts symlink coverage exactly where the design claims to close #162; measured, when the protected path is itself a symlink the ABSOLUTE spelling (the only one a host sends) is ADMITTED while the relative one blocks, and the legacy regex classes are immune because search() matches the raw path regardless; the non-adversarial case is the concerning one, a consumer symlinking their board into a monorepo location loses H-22 silently with no diagnostic | M-3: the write-verb list is narrower than the H-05 precedent it cites as its model, leaving nine spellings allowed including `git checkout HEAD -- ` and `git restore` (closed for audit logs by LOG_GIT_RESTORE_RE under #335 with the rationale that they rewrite tracked files through Git itself, and all three planned entries are tracked), `sponge` (present in LOG_DESTROY_RE, dropped here), and `python -c`/`node -e` — the last directly contradicting B1's own stated premise for helper-only, that the sanctioned helper's Python file I/O is the ONLY route, since `python -c` naming the file lexically is exactly that; these are currently UNDECLARED residual rather than accepted residual | disposition: hold remediation until the parallel Opus adversarial pass lands, then remediate once against both reports rather than twice | confidence: high — every finding measured against a synthetic registry, not inferred. diff --git a/.github/scripts/test_hooklib.py b/.github/scripts/test_hooklib.py index 50f022a7..651d1e6c 100644 --- a/.github/scripts/test_hooklib.py +++ b/.github/scripts/test_hooklib.py @@ -320,6 +320,84 @@ def test_decision_log_edit_must_still_be_a_pure_tail_append(self): self.assertFalse(_hooklib.is_tail_append(current, "## DECISION-0001", "## DECISION-0001-REWRITTEN")) +class ClassifyProtectedStateTest(unittest.TestCase): + """T-05a (#564): classify_protected grows a FIFTH class, "state", for a + path the protected-state registry (_protectedstatelib.py, B1) recognizes + - evaluated strictly AFTER the four legacy classes, with ZERO change to + the classifier's return contract (still a bare set of strings; the + POLICY is resolved separately, inside whichever flank's own "state" + branch needs it - not here, and not by this test). + + REGISTRY ships EMPTY at this slice (T-33/T-65/T-66 enroll the three + named consumers later), so exercising the "state" branch needs an + INJECTED registry - patch the module-level _protectedstatelib.REGISTRY + directly (lookup_policy reads it fresh on every call, unlike + _bashguardlib's precompiled _STATE_WRITE_RES which snapshots at import).""" + + def setUp(self): + import _protectedstatelib + self._protectedstatelib = _protectedstatelib + self._orig_registry = _protectedstatelib.REGISTRY + + def tearDown(self): + self._protectedstatelib.REGISTRY = self._orig_registry + + def test_registered_path_classifies_as_state(self): + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + self.assertEqual(_hooklib.classify_protected(target, root), {"state"}) + + def test_unregistered_path_does_not_classify_as_state(self): + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "open-tasks.md") + self.assertNotIn("state", _hooklib.classify_protected(target, root)) + + def test_registered_backslash_path_still_classifies_as_state(self): + # norm_path() folds separators, so a Windows-style query path must + # still hit the registry entry (mirrors the existing + # is_audit_log/is_decisions_path backslash-normalization tests). + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + win_target = target.replace("/", "\\") + self.assertIn("state", _hooklib.classify_protected(win_target, root)) + + def test_state_can_coexist_with_a_legacy_class(self): + # classify_protected itself never resolves an audit/state collision - + # it reports every class a path hits, same as it already does for + # the pre-existing four (#528/#529). T-05b's separate overlap guard + # is what keeps this scenario from ever being LIVE registry data; + # this test only pins that the CLASSIFIER's own composition rule + # (report every hit) still applies once "state" exists. + from _protectedstatelib import ProtectedPolicy + self._protectedstatelib.REGISTRY = { + ".codearbiter/overrides.log": ProtectedPolicy.MARKER_GATED, + } + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "overrides.log") + self.assertEqual(_hooklib.classify_protected(target, root), {"audit", "state"}) + + def test_default_registry_never_classifies_anything_as_state(self): + # A direct positive fact about PRODUCTION classify_protected: the + # registry is empty at this slice, so nothing in a real repo hits + # "state" yet - exercises the REAL default path, not only an + # injected synthetic one. + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + self.assertNotIn("state", _hooklib.classify_protected(target, root)) + + class PathGlobTest(unittest.TestCase): """is_migration_path / is_ci_path / is_deploy_path against default globs (no security-controls.md in the tmp root, so defaults only).""" diff --git a/core/pysrc/_bashguardlib.py b/core/pysrc/_bashguardlib.py index 76907382..ed625c5c 100644 --- a/core/pysrc/_bashguardlib.py +++ b/core/pysrc/_bashguardlib.py @@ -57,6 +57,23 @@ # pre-bash.py — .github/scripts/ # test_hook_guards.py imports it by # this exact name) +# _state_write_res(basename) -> (redirect_re, write_re, git_restore_re, +# interp_re) H-22's per-entry +# shell-flank regex TEMPLATE for one +# protected-state registry entry's +# bare filename (T-08, #564; the +# git-restore and interpreter legs +# added per findings F5/F6) +# _build_state_write_res(registry) -> tuple[(rel_path, policy, redirect_re, +# write_re, git_restore_re, +# interp_re), ...] the compiled set +# for every entry in `registry`; +# `_STATE_WRITE_RES` is this, built +# once at import from the live +# `_protectedstatelib.REGISTRY` +# _check_h22_state(cmd, root) -> None H-22's run_guards() gate — block()s +# on a shell mutation of a registered +# protected-state file import os import re @@ -71,6 +88,12 @@ from _gitexec import git_executable import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE / # gitdir: pointer) project_root() climb (#223) +import _protectedstatelib # H-22's shell flank (T-08, #564) — imported as a + # module (not `from ... import REGISTRY`) so + # _STATE_WRITE_RES below is built from a live + # attribute lookup at import time, never a + # snapshotted name binding. +from _protectedstatelib import ProtectedPolicy, marker_gated_write_admitted # The most recent git-read failure, surfaced in the H-01/H-09b/H-14 fail-closed # block message. "git unavailable or timed out" alone cost a session of root- @@ -414,6 +437,149 @@ def _commit_no_verify_in_cluster(args): r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + GATE_MARKER, re.I, ) +# H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are +# guarded via classify_protected's "state" class (pre-write.py/pre-edit.py, +# T-05a/T-06/T-07); this closes the shell flank the SAME way the four +# pre-existing classes already do above: a redirect into a registered file, +# or a write/delete verb naming it, blocks. +# +# ONE (redirect_re, write_re) pair PER REGISTRY ENTRY, not a single +# alternation over every registered basename (T-08 design ruling): a regex +# defect in one entry's pattern then stays isolated to that entry — and is +# pinned by that entry's own test — instead of one opaque pattern smearing a +# failure across every consumer. `_state_write_res` is the per-entry +# TEMPLATE, called once per registry entry by `_build_state_write_res`. +# +# BARE basename anchor, deliberately WITHOUT a `.codearbiter/` directory +# prefix the way CONTEXT_MD carries one: the redirect-operator prefix +# (`>>?\|?\s*\S*`) and the verb-then-bounded-window shape +# (`\b(verb-list)\b[^|;&]*`) below are copied verbatim from +# CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE (above, ~line 355), but the NAME +# fragment itself mirrors AUDIT_LOG_NAMES/LOG_TRUNC_RE/LOG_DESTROY_RE's +# bare-filename anchor instead. This is not a stylistic choice: B-07/B-08's +# own non-regression cases, and the T-08b lexical residual, both name the +# protected file with NO `.codearbiter/` prefix at all — `taskwrite add -- +# "fix open-tasks.md schema"` and the false-blocking `taskwrite add -- "…tee +# open-tasks.md"` both carry the bare filename inside free-text argv. A +# directory-anchored pattern could neither reproduce the documented residual +# nor catch a real `tee open-tasks.md` run with a cwd already inside +# `.codearbiter/` (where no directory prefix appears in the command text +# either) — it would under-scan exactly the attack this guard exists for. +# The right-edge lookahead DECISION_LOG_SHELL_RE (#528, ~line 355) already +# carries. H-22's bare-basename anchor inherited the over-match closing it +# fixes without it: `rm .codearbiter/open-tasks.md.bak` matched, because the +# basename is a literal PREFIX-substring of the ".bak" spelling and nothing +# required the basename text to END where it should. Requires end-of-string, +# whitespace, a redirect/pipe/separator, or a quote-close immediately after +# the basename — never a bare `\b` word boundary alone, which cannot do this +# job here (a hyphen is a non-word character on BOTH its sides, so `\b` sits +# at a hyphen exactly as readily as at a `/`; it cannot distinguish +# "…/open-tasks.md" from "my-open-tasks.md"). +# +# This closes only the RIGHT-side over-match. The mirror-image LEFT-side one +# (a longer filename that happens to END with the registered basename, e.g. +# `my-open-tasks.md`, `> my-open-tasks.md`) is a KNOWN, ACCEPTED residual of +# the bare-basename anchor design itself (finding F4, #564 follow-up) — a +# left anchor would require knowing the basename is not itself part of a +# longer name, which the bare-anchor design (see the module comment above) +# deliberately does not have enough context to tell apart from a legitimate +# no-directory-prefix spelling. Declared, not merely implicit: see +# security-controls.md's "Protected-state registry (H-22)" section. +_STATE_NAME_RIGHT_EDGE = r"""(?=$|[\s>|;&"'])""" + +# The write-verb list, extended past the CONTEXT_WRITE_RE/DECISIONS_WRITE_RE +# baseline it was copied from (finding F6, #564 follow-up) with verbs +# present in this file's own cited precedents but missing here: `sponge` +# (already in LOG_DESTROY_RE, ~line 317), plus `ln` (a hardlinked/symlinked +# name overwrites whatever sits there with `ln -f`), `install` (coreutils' +# copy-with-permissions — a genuine overwrite verb), `patch` (rewrites a +# file in place from a diff), and `shred` (secure-delete, the ultimate +# destroy). `install`/`ln` both carry a real false-positive cost of their +# own (`npm install`/`pip install` are common phrases; `ln` is a short, +# common token) — accepted under the SAME "ambiguity resolves CLOSED" +# stance this file states at its own top (module docstring) and applies +# throughout (e.g. `cp overrides.log backup`, a mere READ, blocks anyway); +# declared in security-controls.md rather than left an undeclared gap. +_STATE_WRITE_VERBS = ( + r"rm|del|mv|cp|copy|dd|tee|sed|sponge|ln|install|patch|shred|truncate|ni" + r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content" + r"|Out-File|Add-Content" +) + + +def _state_write_res(basename): + r"""`(redirect_re, write_re, git_restore_re, interp_re)` for ONE + protected-state registry entry's bare filename — the compiled set + `_build_state_write_res` returns one of, per entry. See the module + comment above for why this is bare-basename, not directory-anchored, + and `_STATE_NAME_RIGHT_EDGE`/`_STATE_WRITE_VERBS` above for the + right-anchor and extended verb list (finding F4/F6). + + `git_restore_re` (finding F5, #564 follow-up): mirrors H-05's + LOG_GIT_RESTORE_RE (#335) — `git checkout`/`git restore` rewrite a + TRACKED worktree file through git itself, bypassing every filesystem + verb above entirely (all three planned registry entries are tracked + files, so this is not a hypothetical). A SEPARATE pattern, not folded + into the write-verb list: `checkout`/`restore` are git SUBCOMMANDS, not + shell verbs, and matching them needs the `GIT` global-options-tolerant + prefix the write-verb list has no business carrying. Deliberately does + NOT match `git add` — B-07's non-regression (commit-gate Phase 7 runs + `git add open-tasks.md` on every retained board flip, which must never + trip H-22) — and structurally cannot: the subcommand alternation here is + only `checkout|restore`. + + `interp_re` (finding F6, #564 follow-up): mirrors GATE_MARKER_INTERP_RE + (#237) — an arbitrary interpreter one-liner + (`python -c "open('open-tasks.md','w')..."`) reuses `helper-only`'s own + sanctioned Python file-I/O route while naming the target file lexically, + a flank no verb-list spelling above can see. `[\s\S]*` (not `[^\n]*`, + per the #237 follow-up) so the interpreter token and the filename may + sit on different physical lines of the SAME multi-line `-c`/`-e` + payload — `[^\n]*` cannot cross that newline and would silently reopen + the identical hole in its multi-line spelling.""" + name = re.escape(basename) + redirect_re = re.compile( + r">>?\|?\s*\S*" + name + _STATE_NAME_RIGHT_EDGE, re.I) + write_re = re.compile( + r"\b(" + _STATE_WRITE_VERBS + r")\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + git_restore_re = re.compile( + GIT + r"\s+(?:checkout|restore)\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + interp_re = re.compile( + r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + return redirect_re, write_re, git_restore_re, interp_re + + +def _build_state_write_res(registry): + """`(rel_path, policy, redirect_re, write_re, git_restore_re, + interp_re)` for every entry in `registry`, keyed on each entry's bare + basename via `_state_write_res`. An explicit `registry` PARAMETER (not a + bare comprehension over the module-level default) so a test can rebuild + this exact tuple against a SYNTHETIC registry — the real one ships EMPTY + at this slice (T-01–T-08; T-33/T-65/T-66 enroll the three named + consumers later) — the same `registry=`-parameter shape + `_protectedstatelib.lookup_policy` already uses for the identical + reason.""" + built = [] + for rel_path, policy in registry.items(): + basename = rel_path.replace("\\", "/").rsplit("/", 1)[-1] + redirect_re, write_re, git_restore_re, interp_re = _state_write_res(basename) + built.append((rel_path, policy, redirect_re, write_re, git_restore_re, interp_re)) + return tuple(built) + + +# performance-002/_scopelib.py:109-117 precedent: compiled ONCE at import +# from the live (code-constant, never disk-loaded — #564 design ruling) +# registry, not recompiled per call. Empty at this slice, so +# `_check_h22_state` is correctly a no-op against every command until a +# consumer is registered. A test exercises the real logic by rebuilding this +# EXACT tuple against a synthetic registry (`_build_state_write_res`), never +# by mutating `_protectedstatelib.REGISTRY` after the fact — this tuple +# would not see that (it is a one-time import-time snapshot, by design). +_STATE_WRITE_RES = _build_state_write_res(_protectedstatelib.REGISTRY) + def git_cwd(cmd, root): """The directory a `git -C

` invocation actually targets, or `root` @@ -1042,6 +1208,50 @@ def _gate_marker_hit(view): "prohibited.") +def _check_h22_state(cmd, root): + """H-22: the protected-state registry's shell flank (B-04/B1, #564). + Walks the precompiled per-entry pairs (`_STATE_WRITE_RES`); a command + that redirects into, or runs a write/delete verb against, a registered + basename either admits (marker-gated, under a FRESH authoring marker — + `_protectedstatelib.marker_gated_write_admitted`, the H-11 pattern) or + blocks outright (helper-only/append-only — flank-IDENTICAL: the + distinction between them lives entirely in what the sanctioned helper's + OWN append verb is allowed to do, never in this shell guard, which has + no marker path for either). + + Marker checks read from the pinned `root` (project_root), never `cwd` — + the SAME split `_check_h09b_h10b_crypto_secret`/`_check_h14_migration` + already draw (D-2, `_effective_exec_root`'s own docstring): a linked + worktree has `.codearbiter/` (tracked) but not `.codearbiter/.markers/` + (gitignored), so marker paths must stay anchored at the main checkout. + + Git verbs are deliberately ABSENT from the write-verb list (the same + list `CONTEXT_WRITE_RE`/`DECISIONS_WRITE_RE` already use) — `git add + open-tasks.md` (commit-gate Phase 7, run on every retained board flip) + must never reach a block here (B-07), or commit-gate would block itself + on its own sanctioned board-flip staging. `git checkout`/`git restore` + ARE covered, but via the separate `git_restore_re` leg (finding F5, + #564 follow-up) — never the general write-verb list — precisely so that + isolation holds: `git_restore_re`'s subcommand alternation is only + `checkout|restore`, so it structurally cannot also catch `git add`.""" + for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in _STATE_WRITE_RES: + if not (redirect_re.search(cmd) or write_re.search(cmd) + or git_restore_re.search(cmd) or interp_re.search(cmd)): + continue + if policy == ProtectedPolicy.MARKER_GATED and marker_gated_write_admitted(rel_path, root): + continue + if policy == ProtectedPolicy.MARKER_GATED: + block("H-22", f"'{rel_path}' is marker-gated protected project state (#564) — a " + f"shell redirect or write/delete verb naming it is admitted only " + f"under a fresh authoring marker. Mint the marker via the sanctioned " + f"authoring lane, or /override.") + else: + block("H-22", f"'{rel_path}' is protected project state (#564, " + f"policy={policy.value}) — shell redirects and write/delete verbs " + f"naming it are prohibited outright; there is no marker path for " + f"this policy. Use the sanctioned helper.") + + def _check_h09b_h10b_crypto_secret(commit, add, cwd, root): """H-09b / H-10b: BLOCK a commit that introduces crypto/secret changes without a recorded security-gate pass. The crypto-compliance / secret-handling skills @@ -1229,6 +1439,7 @@ def run_guards(payload, root, ti): _check_h11_decisions(cmd) _check_h18_context_md(cmd) _check_h19_gate_marker(git_view, cmd, heredoc_shell_fallback) + _check_h22_state(cmd, root) _check_h09b_h10b_crypto_secret(commit, add, cwd, root) _check_h14_migration(commit, add, cwd, root) diff --git a/core/pysrc/_pathnorm.py b/core/pysrc/_pathnorm.py index c63dc6ae..a4ca0fcc 100644 --- a/core/pysrc/_pathnorm.py +++ b/core/pysrc/_pathnorm.py @@ -42,3 +42,33 @@ def repo_rel(fpath, root): rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root)) rel = rel.replace(os.sep, "/") return "" if rel == ".." or rel.startswith("../") else rel + + +def raw_repo_rel(fpath, root): + """`repo_rel`'s realpath-FREE twin: the repo-relative POSIX path for + `fpath` computed by pure lexical arithmetic against `root`, resolving + NEITHER side's symlinks — the raw spelling a host actually sent, or "" + when it cannot be expressed relative to `root` at all (outside root, or + on Windows a different drive). + + Exists for the classifiers that need BOTH the realpath-resolved form + AND the unresolved one to reproduce the #162 symlink-safety property + (`classify_protected` already tries a raw and a realpath'd form for + every legacy class, via regex `.search()` on the raw normalized string — + a shape that happens to also cover "the protected path itself is a + symlink" for those classes, because the pattern matches the raw text + regardless of what it resolves to). The equality-based `state` class + (`_protectedstatelib.lookup_policy`) has no equivalent for-free coverage + from a bare `norm_path(fpath)` — that string is very often absolute, + never equal to a repo-relative registry key — so a caller needing the + same raw-spelling coverage for an EQUALITY-based lookup should resolve + `fpath` against `root` here first, exactly like `repo_rel` does, just + without the `os.path.realpath()` call (T-06/F3, #564 follow-up).""" + if not fpath: + return "" + try: + rel = os.path.relpath(os.path.normpath(fpath), os.path.normpath(root)) + except ValueError: + return "" # Windows: fpath and root on different drives + rel = rel.replace(os.sep, "/") + return "" if rel == ".." or rel.startswith("../") else rel diff --git a/core/pysrc/_protectedlib.py b/core/pysrc/_protectedlib.py index d71d770a..e5ca02a2 100644 --- a/core/pysrc/_protectedlib.py +++ b/core/pysrc/_protectedlib.py @@ -20,12 +20,25 @@ # # _hooklib re-exports every name below, so no consumer changed and the # pre-existing hook suites prove parity without moving. +# +# T-05a (#564): a FIFTH class, "state", joins the four above — a path +# registered in the protected-state registry (_protectedstatelib.py, B1: a +# per-entry policy of marker-gated/helper-only/append-only). It is evaluated +# strictly AFTER the four legacy classes (a documented ordering, not just an +# implementation detail — see classify_protected's own docstring) and +# changes nothing about the RETURN CONTRACT: still a bare set of class-name +# strings, exactly as before. Resolving the registered path's POLICY is left +# entirely to whichever flank branch consumes the "state" tag +# (_protectedstatelib.lookup_policy / resolve_registered_path) — never done +# here, so this module gains no dependency on a POLICY concept it has no +# other reason to know about, and the four pre-existing consumers of +# classify_protected see zero contract change. from __future__ import annotations import re -from _pathnorm import norm_path, repo_rel +from _pathnorm import norm_path, raw_repo_rel, repo_rel # Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized @@ -110,6 +123,25 @@ # legitimately shell-written (the sanctioned producers are the python # security-pass.py / migration-pass.py helpers), unlike adr-authoring-active # which /adr legitimately `touch`es. +# +# T-08a / #564: this set enumerates markers whose PRESENCE converts a +# hard-gate BLOCK into an ALLOW (H-09b/H-10b's security-gate-passed, H-14's +# migration-gate-passed) — that conversion is the membership criterion, not +# "is this a marker under .markers/". A protected-state AUTHORING marker +# (e.g. release-targets-authoring, and the `-authoring` shape every +# future marker-gated consumer follows — _protectedstatelib.marker_name_for) +# does NOT belong here even though it also gates a write: it authorizes +# nothing on its own, the content it gates still passes through ordinary +# review, and it is deliberately self-mintable by an agent (a plain `touch`) +# under ADR-0010 — same as adr-authoring-active above. Its purpose is audit +# friction, not authorization. +# +# The risk here runs opposite to intuition: a future "hardening" pass that +# widens this blocklist to cover every registered marker would brick every +# legitimate authoring lane (a marker an agent can never touch admits +# nothing, ever) while stopping no non-cooperative agent (who can bypass +# this guard's own enforcement the same way they could bypass any other +# lexical shell check). See test_marker_touch_allowed (T-08a) for the pin. GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)" @@ -177,6 +209,44 @@ def is_marker_path(rel): return bool(MARKERS_RE.search(norm_path(rel))) +def is_registered_state_path(rel): + """True iff `rel` carries a policy in the protected-state registry + (`_protectedstatelib.lookup_policy`, B1/#564) — the fifth protected class + `classify_protected` recognizes (T-05a), evaluated strictly AFTER the + other four. + + The `_protectedstatelib` import is deliberately LAZY — done here, inside + the function body, rather than at this module's top level. + `_protectedstatelib` imports `marker_fresh` from `_hooklib`, and + `_hooklib` imports `classify_protected` (and every other name in this + file) FROM here — a module-level `import _protectedstatelib` at the top + of `_protectedlib.py` would be a genuine three-module import cycle + (`_protectedlib` -> `_protectedstatelib` -> `_hooklib` -> `_protectedlib`) + that Python cannot resolve at module-load time. Deferring the import to + CALL time breaks the cycle without restructuring the dependency graph: + every real caller reaches this function only after `_hooklib` (and + therefore this module) has already finished its own top-level execution, + so by the time the deferred import statement actually runs, + `_protectedstatelib`'s own `from _hooklib import marker_fresh` resolves + against an already-complete `_hooklib` module. + + Correction (finding F11, #564 follow-up): this is NOT the same pattern + `_babysitlib.babysit_config` or `statusline.py` use — neither of those + actually breaks an import CYCLE. `statusline.py`'s function-level + imports are a startup-latency lazy-load with no cyclic partner at all, + and `_babysitlib.py`'s deferred import sits inside a lazy default + injection where `_hooklib` never imports `_babysitlib` back (no cycle + to break). The deferral here IS still correct and necessary — it is + the only thing that breaks the genuine three-module cycle described + above — the prior comment simply cited the wrong precedent; corrected + so the comment matches the record. + + It is a local import, not file I/O or a git call, so it does not violate + this module's zero-side-effects-at-import invariant.""" + import _protectedstatelib + return _protectedstatelib.lookup_policy(rel) is not None + + def classify_protected(fpath, root): """The set of protected classes a Write/Edit `fpath` targets, resolving symlinks (#162). Each classifier runs against BOTH the raw normalized path @@ -184,11 +254,43 @@ def classify_protected(fpath, root): path lacks `.codearbiter/` still realpaths back inside the repo, so an alias can no longer launder a write past the guard. Centralized so pre-write.py and pre-edit.py apply the identical symlink-safe check to every class (H-05, - H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice. + H-11, #159 CONTEXT.md, #160 markers, #564 protected-state) instead of + re-encoding it twice. + + Classes: "audit", "decisions", "context", "marker", "state". repo_rel() + returns "" for a target outside the repo (which cannot be a + `.codearbiter` path), so that flank is simply skipped. - Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for - a target outside the repo (which cannot be a `.codearbiter` path), so that - flank is simply skipped.""" + "state" (T-05a, #564) is resolved in a SEPARATE loop below, strictly + AFTER the four legacy classes above — not interleaved into their loop. + That split is about incremental extension safety (a class added later + stays visibly separate from the four it was added beside, per the + module-header rationale above), NOT about the hits it produces: + `hits` is a `set`, so the SECOND loop's position relative to the first + has no observable effect on `classify_protected`'s own return value — + no test (mutant or otherwise) can distinguish "state resolved in loop 2" + from "state resolved inside loop 1" from the returned set alone + (finding F12, #564 follow-up correcting an earlier, overselling version + of this paragraph). The ordering that DOES matter or a real class/tag + conflict lives elsewhere and IS enforced in code: the textual branch + order of the `if "..." in classes:` checks in pre-write.py/pre-edit.py, + and pre-edit.py's own `_CLASS_TAG` priority tuple, which decide which + ONE message a caller emits when a path hits more than one class. + + The "state" loop tries BOTH a raw (symlink-unresolved) repo-relative + form (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`) — the SAME #162 two-form symlink-safety property the four + legacy classes get for free from `.search()`-based regex matching over + the raw path text, restored here explicitly (finding F3) because an + EQUALITY-based lookup (`_protectedstatelib.lookup_policy`) does not get + that coverage for free from a bare `norm_path(fpath)` — see + `_protectedstatelib.resolve_registered_path`'s docstring for the full + account of the symlink-inversion bug this closes. The return contract is + unchanged by this addition: still a bare set of strings. A caller that + needs the registered path's POLICY (marker-gated/helper-only/ + append-only) resolves it separately, via `_protectedstatelib` — this + function only ever reports CLASS membership, exactly as it always has, + so its four pre-existing consumers see no contract change.""" hits = set() for p in (norm_path(fpath), repo_rel(fpath, root)): if not p: @@ -201,4 +303,10 @@ def classify_protected(fpath, root): hits.add("context") if is_marker_path(p): hits.add("marker") + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + if is_registered_state_path(p): + hits.add("state") + break return hits diff --git a/core/pysrc/_protectedstatelib.py b/core/pysrc/_protectedstatelib.py index 82c87561..b2f93e7e 100644 --- a/core/pysrc/_protectedstatelib.py +++ b/core/pysrc/_protectedstatelib.py @@ -60,6 +60,21 @@ # dict, which would drop the # raw-and-realpath symlink coverage that # dispatch provides. +# resolve_registered_path(fpath, root, registry=None) +# -> (rel_path, ProtectedPolicy) | (None, +# None). The T-06/T-07 flank helper: once +# classify_protected has already reported +# "state" for `fpath`, this resolves +# WHICH registered path matched and WHICH +# policy it carries, trying both the raw +# normalized path and its +# realpath-resolved repo-relative form - +# the SAME two forms classify_protected +# itself tries (#162) - so the flank +# resolves the identical entry +# classify_protected saw rather than +# re-deriving membership through an +# independent check. # MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches # the ADR-authoring gate's value by # convention, not a shared import - see @@ -106,7 +121,7 @@ from enum import Enum from _hooklib import marker_fresh -from _pathnorm import norm_path +from _pathnorm import norm_path, raw_repo_rel, repo_rel class ProtectedPolicy(str, Enum): @@ -141,17 +156,70 @@ class ProtectedPolicy(str, Enum): REGISTRY: dict[str, ProtectedPolicy] = {} +def _canon(rel_path): + """Canonical comparison form of a repo-relative path: separator- + normalized (`norm_path`), then whitespace-stripped, `./`-prefix- + stripped (repeatable — "././x" too), doubled-slash-collapsed, + trailing-slash-stripped, and finally case-folded. + + Applied to BOTH sides of every `lookup_policy` comparison (the query + path AND every registry key) so a spelling difference on either side + degrades to "still matches" rather than "silently matches nothing" — + the same "a malformed key degrades the way a malformed query path does" + principle `lookup_policy`'s own docstring states, extended to cover the + specific spellings its docstring already promised but the OLD + norm_path-only comparison silently missed (#564 follow-up, finding F2): + a leading `./`, a trailing slash, a doubled slash, and a leading space. + + Case-folded — not merely separator-normalized — for a second, + independent reason (finding F1): `_bashguardlib.py`'s H-22 shell-flank + regexes compile with `re.I` (`_state_write_res`), so a case-sensitive + equality check here would let the two flanks disagree on whether a + differently-cased spelling of a registered path is protected. On a + case-preserving-but-insensitive filesystem (default macOS/APFS, + Windows/NTFS) that disagreement is a live fail-open: `_protectedlib. + classify_protected` resolves through `os.path.realpath`, which does + NOT canonicalize case for an EXISTING path on a case-insensitive mount + (posixpath.realpath never folds case at all; even `nt.realpath`, which + does resolve an existing file's on-disk case, cannot help a NOT-YET- + created file — exactly the Write that creates a protected-state file + for the first time) — so `Write(".codearbiter/Open-Tasks.md")` could + reach this equality check with a case that never gets folded away + before comparison. This module deliberately picks ONE fixed rule, + case-INSENSITIVE, GLOBALLY, rather than "whatever this host's + filesystem happens to do": matching host behavior is not obviously + right either (it varies per platform AND per volume on the same + platform), and a fixed global rule is the only option `_bashguardlib.py` + can mirror without itself inspecting the filesystem. Choosing + case-INSENSITIVE (not case-sensitive) only WIDENS what H-22 protects — + consistent with this codebase's "ambiguity resolves CLOSED" stance + (module comment, `_bashguardlib.py`) — at the cost of a same-directory + file whose name differs from a registered path ONLY by case (e.g. a + genuinely different `OPEN-TASKS.MD`) being treated as protected too; a + registry entry choosing a name that collides with a real sibling file + under a case change is expected to be rare enough that this is judged + the right trade.""" + p = norm_path(rel_path).strip() + while p.startswith("./"): + p = p[2:] + while "//" in p: + p = p.replace("//", "/") + p = p.rstrip("/") + return p.lower() + + def lookup_policy(rel_path, registry=None): """The ProtectedPolicy registered for `rel_path`, or None if it carries - no policy. Both `rel_path` and every registry key are separator- - normalized before comparison, so a Windows backslash path matches a - registry keyed with forward slashes (the same normalization every other - _*lib.py classifier applies via norm_path) AND a registry entry that was - itself typo'd with a backslash, a `./` prefix character, or a trailing - slash still matches rather than silently protecting nothing - a - malformed *key* degrades the same way a malformed query path does; only a - malformed *policy* (see ProtectedPolicy) is an internal error worth - raising on. + no policy. Both `rel_path` and every registry key are canonicalized + (`_canon`, above) before comparison — separator-normalized, `./`/ + doubled-slash/trailing-slash/leading-space tolerant, and + case-INSENSITIVE (deliberately, globally — see `_canon`'s docstring for + why) — so a Windows backslash path, a `./`-prefixed or trailing-slash + query, or a differently-cased spelling all match a registry entry, AND + a registry entry that was itself typo'd any of those ways still matches + rather than silently protecting nothing - a malformed *key* degrades the + same way a malformed query path does; only a malformed *policy* (see + ProtectedPolicy) is an internal error worth raising on. `registry` defaults to the module-level REGISTRY; a test (or a future caller) may pass a synthetic dict instead, which is what keeps this @@ -163,13 +231,67 @@ def lookup_policy(rel_path, registry=None): alias this module does not itself guard against.""" if registry is None: registry = REGISTRY - normalized = norm_path(rel_path) + normalized = _canon(rel_path) for key, policy in registry.items(): - if norm_path(key) == normalized: + if _canon(key) == normalized: return policy return None +def resolve_registered_path(fpath, root, registry=None): + """The `(rel_path, policy)` pair a Write/Edit/shell target resolves to, + once `_protectedlib.classify_protected` has already reported "state" for + it - or `(None, None)` if it turns out to carry no registry entry after + all (a caller that checks this before ever consulting + classify_protected, or a stale class set). + + Tries BOTH the raw (symlink-unresolved) repo-relative form + (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`), in that order - the SAME two-form symlink-safety property + (#162) classify_protected's four legacy classes get automatically from + running a regex `.search()` over the raw normalized path text. + + That automatic coverage does NOT transfer for free to this module's + EQUALITY-based lookup (finding F3, #564 follow-up): `norm_path(fpath)` - + almost always an ABSOLUTE path, since every host sends one - is never + equal to a repo-relative registry key, so trying it as the "raw" leg was + inert (it could never match anything). Worse, it made symlink coverage + the WRONG WAY ROUND versus the other four classes: when the REGISTERED + PATH ITSELF is a symlink pointing somewhere unregistered, + `os.path.realpath` resolves the ONLY spelling a host actually sends + (the absolute path) straight through the symlink to that unregistered + target, and the dead raw leg supplied no alternative route back to the + registered name — so the write was silently ADMITTED, the opposite of + the legacy classes' behavior in the equivalent scenario (a regex + `.search()` still matches the raw path text regardless of where it + realpaths to). `raw_repo_rel` fixes this: computed by pure lexical + arithmetic against `root` (no `os.path.realpath` call), it still names + the registered entry syntactically even when the path is a symlink, so + that spelling now resolves correctly too - restoring the SAME + "protected either way you spell it" guarantee the legacy classes + already had; the realpath leg still exists for the mirror-image case + (a symlinked DIRECTORY whose visible path lacks the registered prefix + but resolves into it). + + So a flank reaching this function resolves the IDENTICAL entry + classify_protected saw, rather than re-deriving membership through an + independent check. That independent-check shape is exactly what #564's + design forbids ("no second, parallel lookup") - this function only ever + RESOLVES what classify_protected already decided; it never decides + membership on its own account. + + `registry` defaults to the module-level REGISTRY, matching + `lookup_policy`'s own parameter shape, for the same reason: a test (or a + future caller) may pass a synthetic dict.""" + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + policy = lookup_policy(p, registry) + if policy is not None: + return p, policy + return None, None + + # The H-11 authoring-marker freshness window, matching the existing # ADR-authoring gate's value by convention, NOT by a shared import: # pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each diff --git a/core/pysrc/pre-edit.py b/core/pysrc/pre-edit.py index e08a2ca1..24eb2067 100644 --- a/core/pysrc/pre-edit.py +++ b/core/pysrc/pre-edit.py @@ -11,6 +11,10 @@ # be the file's REAL current trailing content, new_string must extend # it, and replace_all is rejected outright (reliability-003, #172). # H-11 ADRs under decisions/ are edited only via /adr. +# H-22 the protected-state registry (B1/#564): the SAME generic branch as +# pre-write.py — marker-gated admits only under a fresh authoring +# marker; helper-only and append-only are hard-blocked, no marker +# path at all. # # NotebookEdit is guarded too (a notebook has no append/frontmatter semantics, so # a protected target is refused outright) — defense in depth; none of the @@ -38,6 +42,9 @@ get_host, is_tail_append, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _read_real_text(root, fpath): @@ -93,7 +100,8 @@ def _run(root): # The tag that names each protected class, in message priority order. _CLASS_TAG = (("marker", "H-19"), ("context", "H-18"), - ("audit", "H-05"), ("decisions", "H-11")) + ("audit", "H-05"), ("decisions", "H-11"), + ("state", "H-22")) # H-21: an "opaque" op is a host's signal that it could not map this # payload to a known per-file shape at all (e.g. a FailClosedHost, or a @@ -207,6 +215,29 @@ def _run(root): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — the SAME generic branch + # pre-write.py carries (T-06/T-07 design ruling: one branch, not a + # per-tool special case). classify_protected already decided "state" is + # a hit; resolve_registered_path only resolves WHICH registered path and + # WHICH policy — never a second, independent membership check. + # marker-gated admits only under a fresh authoring marker; helper-only + # and append-only are flank-IDENTICAL here — both hard-block + # unconditionally, with NO marker path and no tail-anchored-append + # admission (unlike H-05's audit logs, an append-only registry entry's + # append verb lives in the sanctioned HELPER, not in this Edit flank). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — an " + f"Edit is admitted only under a fresh authoring marker. Run the " + f"sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Edit tools may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + sys.exit(0) diff --git a/core/pysrc/pre-write.py b/core/pysrc/pre-write.py index d0ec3088..b57c5747 100644 --- a/core/pysrc/pre-write.py +++ b/core/pysrc/pre-write.py @@ -11,6 +11,12 @@ # BLOCK into an allow (#160) — never writable via the Write tool. # H-05 audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) are append-only. # H-11 ADRs under decisions/ are authored only via /adr. +# H-22 the protected-state registry (B1/#564): a marker-gated entry admits +# only under a fresh authoring marker; a helper-only or append-only +# entry is hard-blocked with NO marker path at all — one generic +# branch resolves the entry's policy via _protectedstatelib and +# applies it, so a fourth registered file needs a registry entry, +# never a new hook branch. # # Every protected-path decision resolves symlinks (#162): classify_protected() # checks the raw path AND its realpath-resolved repo-relative form, so a symlink @@ -28,6 +34,9 @@ arbiter_active, block, classify_protected, frontmatter_enabled_text, get_host, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _guard_op(root, op): @@ -116,6 +125,29 @@ def _guard_op(root, op): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — ONE generic branch, + # regardless of `kind`. classify_protected already decided "state" is a + # hit; resolve_registered_path only resolves WHICH registered path + # matched and WHICH policy it carries — never a second, independent + # membership check (T-06 design ruling). marker-gated admits only under a + # fresh authoring marker; helper-only and append-only are + # flank-IDENTICAL here — both hard-block unconditionally, with NO marker + # path at all. The policies differ only in what the SANCTIONED HELPER's + # own file I/O is allowed to do, which this tool-call flank never sees by + # construction (its argv/content never reaches this guard). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — a " + f"Write is admitted only under a fresh authoring marker. Run " + f"the sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Write tool may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + def _run(root): # The host seam (ADR-0011, M2): iter_file_ops maps this host's native diff --git a/plugins/ca-codex/hooks/_bashguardlib.py b/plugins/ca-codex/hooks/_bashguardlib.py index 76907382..ed625c5c 100644 --- a/plugins/ca-codex/hooks/_bashguardlib.py +++ b/plugins/ca-codex/hooks/_bashguardlib.py @@ -57,6 +57,23 @@ # pre-bash.py — .github/scripts/ # test_hook_guards.py imports it by # this exact name) +# _state_write_res(basename) -> (redirect_re, write_re, git_restore_re, +# interp_re) H-22's per-entry +# shell-flank regex TEMPLATE for one +# protected-state registry entry's +# bare filename (T-08, #564; the +# git-restore and interpreter legs +# added per findings F5/F6) +# _build_state_write_res(registry) -> tuple[(rel_path, policy, redirect_re, +# write_re, git_restore_re, +# interp_re), ...] the compiled set +# for every entry in `registry`; +# `_STATE_WRITE_RES` is this, built +# once at import from the live +# `_protectedstatelib.REGISTRY` +# _check_h22_state(cmd, root) -> None H-22's run_guards() gate — block()s +# on a shell mutation of a registered +# protected-state file import os import re @@ -71,6 +88,12 @@ from _gitexec import git_executable import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE / # gitdir: pointer) project_root() climb (#223) +import _protectedstatelib # H-22's shell flank (T-08, #564) — imported as a + # module (not `from ... import REGISTRY`) so + # _STATE_WRITE_RES below is built from a live + # attribute lookup at import time, never a + # snapshotted name binding. +from _protectedstatelib import ProtectedPolicy, marker_gated_write_admitted # The most recent git-read failure, surfaced in the H-01/H-09b/H-14 fail-closed # block message. "git unavailable or timed out" alone cost a session of root- @@ -414,6 +437,149 @@ def _commit_no_verify_in_cluster(args): r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + GATE_MARKER, re.I, ) +# H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are +# guarded via classify_protected's "state" class (pre-write.py/pre-edit.py, +# T-05a/T-06/T-07); this closes the shell flank the SAME way the four +# pre-existing classes already do above: a redirect into a registered file, +# or a write/delete verb naming it, blocks. +# +# ONE (redirect_re, write_re) pair PER REGISTRY ENTRY, not a single +# alternation over every registered basename (T-08 design ruling): a regex +# defect in one entry's pattern then stays isolated to that entry — and is +# pinned by that entry's own test — instead of one opaque pattern smearing a +# failure across every consumer. `_state_write_res` is the per-entry +# TEMPLATE, called once per registry entry by `_build_state_write_res`. +# +# BARE basename anchor, deliberately WITHOUT a `.codearbiter/` directory +# prefix the way CONTEXT_MD carries one: the redirect-operator prefix +# (`>>?\|?\s*\S*`) and the verb-then-bounded-window shape +# (`\b(verb-list)\b[^|;&]*`) below are copied verbatim from +# CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE (above, ~line 355), but the NAME +# fragment itself mirrors AUDIT_LOG_NAMES/LOG_TRUNC_RE/LOG_DESTROY_RE's +# bare-filename anchor instead. This is not a stylistic choice: B-07/B-08's +# own non-regression cases, and the T-08b lexical residual, both name the +# protected file with NO `.codearbiter/` prefix at all — `taskwrite add -- +# "fix open-tasks.md schema"` and the false-blocking `taskwrite add -- "…tee +# open-tasks.md"` both carry the bare filename inside free-text argv. A +# directory-anchored pattern could neither reproduce the documented residual +# nor catch a real `tee open-tasks.md` run with a cwd already inside +# `.codearbiter/` (where no directory prefix appears in the command text +# either) — it would under-scan exactly the attack this guard exists for. +# The right-edge lookahead DECISION_LOG_SHELL_RE (#528, ~line 355) already +# carries. H-22's bare-basename anchor inherited the over-match closing it +# fixes without it: `rm .codearbiter/open-tasks.md.bak` matched, because the +# basename is a literal PREFIX-substring of the ".bak" spelling and nothing +# required the basename text to END where it should. Requires end-of-string, +# whitespace, a redirect/pipe/separator, or a quote-close immediately after +# the basename — never a bare `\b` word boundary alone, which cannot do this +# job here (a hyphen is a non-word character on BOTH its sides, so `\b` sits +# at a hyphen exactly as readily as at a `/`; it cannot distinguish +# "…/open-tasks.md" from "my-open-tasks.md"). +# +# This closes only the RIGHT-side over-match. The mirror-image LEFT-side one +# (a longer filename that happens to END with the registered basename, e.g. +# `my-open-tasks.md`, `> my-open-tasks.md`) is a KNOWN, ACCEPTED residual of +# the bare-basename anchor design itself (finding F4, #564 follow-up) — a +# left anchor would require knowing the basename is not itself part of a +# longer name, which the bare-anchor design (see the module comment above) +# deliberately does not have enough context to tell apart from a legitimate +# no-directory-prefix spelling. Declared, not merely implicit: see +# security-controls.md's "Protected-state registry (H-22)" section. +_STATE_NAME_RIGHT_EDGE = r"""(?=$|[\s>|;&"'])""" + +# The write-verb list, extended past the CONTEXT_WRITE_RE/DECISIONS_WRITE_RE +# baseline it was copied from (finding F6, #564 follow-up) with verbs +# present in this file's own cited precedents but missing here: `sponge` +# (already in LOG_DESTROY_RE, ~line 317), plus `ln` (a hardlinked/symlinked +# name overwrites whatever sits there with `ln -f`), `install` (coreutils' +# copy-with-permissions — a genuine overwrite verb), `patch` (rewrites a +# file in place from a diff), and `shred` (secure-delete, the ultimate +# destroy). `install`/`ln` both carry a real false-positive cost of their +# own (`npm install`/`pip install` are common phrases; `ln` is a short, +# common token) — accepted under the SAME "ambiguity resolves CLOSED" +# stance this file states at its own top (module docstring) and applies +# throughout (e.g. `cp overrides.log backup`, a mere READ, blocks anyway); +# declared in security-controls.md rather than left an undeclared gap. +_STATE_WRITE_VERBS = ( + r"rm|del|mv|cp|copy|dd|tee|sed|sponge|ln|install|patch|shred|truncate|ni" + r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content" + r"|Out-File|Add-Content" +) + + +def _state_write_res(basename): + r"""`(redirect_re, write_re, git_restore_re, interp_re)` for ONE + protected-state registry entry's bare filename — the compiled set + `_build_state_write_res` returns one of, per entry. See the module + comment above for why this is bare-basename, not directory-anchored, + and `_STATE_NAME_RIGHT_EDGE`/`_STATE_WRITE_VERBS` above for the + right-anchor and extended verb list (finding F4/F6). + + `git_restore_re` (finding F5, #564 follow-up): mirrors H-05's + LOG_GIT_RESTORE_RE (#335) — `git checkout`/`git restore` rewrite a + TRACKED worktree file through git itself, bypassing every filesystem + verb above entirely (all three planned registry entries are tracked + files, so this is not a hypothetical). A SEPARATE pattern, not folded + into the write-verb list: `checkout`/`restore` are git SUBCOMMANDS, not + shell verbs, and matching them needs the `GIT` global-options-tolerant + prefix the write-verb list has no business carrying. Deliberately does + NOT match `git add` — B-07's non-regression (commit-gate Phase 7 runs + `git add open-tasks.md` on every retained board flip, which must never + trip H-22) — and structurally cannot: the subcommand alternation here is + only `checkout|restore`. + + `interp_re` (finding F6, #564 follow-up): mirrors GATE_MARKER_INTERP_RE + (#237) — an arbitrary interpreter one-liner + (`python -c "open('open-tasks.md','w')..."`) reuses `helper-only`'s own + sanctioned Python file-I/O route while naming the target file lexically, + a flank no verb-list spelling above can see. `[\s\S]*` (not `[^\n]*`, + per the #237 follow-up) so the interpreter token and the filename may + sit on different physical lines of the SAME multi-line `-c`/`-e` + payload — `[^\n]*` cannot cross that newline and would silently reopen + the identical hole in its multi-line spelling.""" + name = re.escape(basename) + redirect_re = re.compile( + r">>?\|?\s*\S*" + name + _STATE_NAME_RIGHT_EDGE, re.I) + write_re = re.compile( + r"\b(" + _STATE_WRITE_VERBS + r")\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + git_restore_re = re.compile( + GIT + r"\s+(?:checkout|restore)\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + interp_re = re.compile( + r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + return redirect_re, write_re, git_restore_re, interp_re + + +def _build_state_write_res(registry): + """`(rel_path, policy, redirect_re, write_re, git_restore_re, + interp_re)` for every entry in `registry`, keyed on each entry's bare + basename via `_state_write_res`. An explicit `registry` PARAMETER (not a + bare comprehension over the module-level default) so a test can rebuild + this exact tuple against a SYNTHETIC registry — the real one ships EMPTY + at this slice (T-01–T-08; T-33/T-65/T-66 enroll the three named + consumers later) — the same `registry=`-parameter shape + `_protectedstatelib.lookup_policy` already uses for the identical + reason.""" + built = [] + for rel_path, policy in registry.items(): + basename = rel_path.replace("\\", "/").rsplit("/", 1)[-1] + redirect_re, write_re, git_restore_re, interp_re = _state_write_res(basename) + built.append((rel_path, policy, redirect_re, write_re, git_restore_re, interp_re)) + return tuple(built) + + +# performance-002/_scopelib.py:109-117 precedent: compiled ONCE at import +# from the live (code-constant, never disk-loaded — #564 design ruling) +# registry, not recompiled per call. Empty at this slice, so +# `_check_h22_state` is correctly a no-op against every command until a +# consumer is registered. A test exercises the real logic by rebuilding this +# EXACT tuple against a synthetic registry (`_build_state_write_res`), never +# by mutating `_protectedstatelib.REGISTRY` after the fact — this tuple +# would not see that (it is a one-time import-time snapshot, by design). +_STATE_WRITE_RES = _build_state_write_res(_protectedstatelib.REGISTRY) + def git_cwd(cmd, root): """The directory a `git -C ` invocation actually targets, or `root` @@ -1042,6 +1208,50 @@ def _gate_marker_hit(view): "prohibited.") +def _check_h22_state(cmd, root): + """H-22: the protected-state registry's shell flank (B-04/B1, #564). + Walks the precompiled per-entry pairs (`_STATE_WRITE_RES`); a command + that redirects into, or runs a write/delete verb against, a registered + basename either admits (marker-gated, under a FRESH authoring marker — + `_protectedstatelib.marker_gated_write_admitted`, the H-11 pattern) or + blocks outright (helper-only/append-only — flank-IDENTICAL: the + distinction between them lives entirely in what the sanctioned helper's + OWN append verb is allowed to do, never in this shell guard, which has + no marker path for either). + + Marker checks read from the pinned `root` (project_root), never `cwd` — + the SAME split `_check_h09b_h10b_crypto_secret`/`_check_h14_migration` + already draw (D-2, `_effective_exec_root`'s own docstring): a linked + worktree has `.codearbiter/` (tracked) but not `.codearbiter/.markers/` + (gitignored), so marker paths must stay anchored at the main checkout. + + Git verbs are deliberately ABSENT from the write-verb list (the same + list `CONTEXT_WRITE_RE`/`DECISIONS_WRITE_RE` already use) — `git add + open-tasks.md` (commit-gate Phase 7, run on every retained board flip) + must never reach a block here (B-07), or commit-gate would block itself + on its own sanctioned board-flip staging. `git checkout`/`git restore` + ARE covered, but via the separate `git_restore_re` leg (finding F5, + #564 follow-up) — never the general write-verb list — precisely so that + isolation holds: `git_restore_re`'s subcommand alternation is only + `checkout|restore`, so it structurally cannot also catch `git add`.""" + for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in _STATE_WRITE_RES: + if not (redirect_re.search(cmd) or write_re.search(cmd) + or git_restore_re.search(cmd) or interp_re.search(cmd)): + continue + if policy == ProtectedPolicy.MARKER_GATED and marker_gated_write_admitted(rel_path, root): + continue + if policy == ProtectedPolicy.MARKER_GATED: + block("H-22", f"'{rel_path}' is marker-gated protected project state (#564) — a " + f"shell redirect or write/delete verb naming it is admitted only " + f"under a fresh authoring marker. Mint the marker via the sanctioned " + f"authoring lane, or /override.") + else: + block("H-22", f"'{rel_path}' is protected project state (#564, " + f"policy={policy.value}) — shell redirects and write/delete verbs " + f"naming it are prohibited outright; there is no marker path for " + f"this policy. Use the sanctioned helper.") + + def _check_h09b_h10b_crypto_secret(commit, add, cwd, root): """H-09b / H-10b: BLOCK a commit that introduces crypto/secret changes without a recorded security-gate pass. The crypto-compliance / secret-handling skills @@ -1229,6 +1439,7 @@ def run_guards(payload, root, ti): _check_h11_decisions(cmd) _check_h18_context_md(cmd) _check_h19_gate_marker(git_view, cmd, heredoc_shell_fallback) + _check_h22_state(cmd, root) _check_h09b_h10b_crypto_secret(commit, add, cwd, root) _check_h14_migration(commit, add, cwd, root) diff --git a/plugins/ca-codex/hooks/_pathnorm.py b/plugins/ca-codex/hooks/_pathnorm.py index c63dc6ae..a4ca0fcc 100644 --- a/plugins/ca-codex/hooks/_pathnorm.py +++ b/plugins/ca-codex/hooks/_pathnorm.py @@ -42,3 +42,33 @@ def repo_rel(fpath, root): rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root)) rel = rel.replace(os.sep, "/") return "" if rel == ".." or rel.startswith("../") else rel + + +def raw_repo_rel(fpath, root): + """`repo_rel`'s realpath-FREE twin: the repo-relative POSIX path for + `fpath` computed by pure lexical arithmetic against `root`, resolving + NEITHER side's symlinks — the raw spelling a host actually sent, or "" + when it cannot be expressed relative to `root` at all (outside root, or + on Windows a different drive). + + Exists for the classifiers that need BOTH the realpath-resolved form + AND the unresolved one to reproduce the #162 symlink-safety property + (`classify_protected` already tries a raw and a realpath'd form for + every legacy class, via regex `.search()` on the raw normalized string — + a shape that happens to also cover "the protected path itself is a + symlink" for those classes, because the pattern matches the raw text + regardless of what it resolves to). The equality-based `state` class + (`_protectedstatelib.lookup_policy`) has no equivalent for-free coverage + from a bare `norm_path(fpath)` — that string is very often absolute, + never equal to a repo-relative registry key — so a caller needing the + same raw-spelling coverage for an EQUALITY-based lookup should resolve + `fpath` against `root` here first, exactly like `repo_rel` does, just + without the `os.path.realpath()` call (T-06/F3, #564 follow-up).""" + if not fpath: + return "" + try: + rel = os.path.relpath(os.path.normpath(fpath), os.path.normpath(root)) + except ValueError: + return "" # Windows: fpath and root on different drives + rel = rel.replace(os.sep, "/") + return "" if rel == ".." or rel.startswith("../") else rel diff --git a/plugins/ca-codex/hooks/_protectedlib.py b/plugins/ca-codex/hooks/_protectedlib.py index d71d770a..e5ca02a2 100644 --- a/plugins/ca-codex/hooks/_protectedlib.py +++ b/plugins/ca-codex/hooks/_protectedlib.py @@ -20,12 +20,25 @@ # # _hooklib re-exports every name below, so no consumer changed and the # pre-existing hook suites prove parity without moving. +# +# T-05a (#564): a FIFTH class, "state", joins the four above — a path +# registered in the protected-state registry (_protectedstatelib.py, B1: a +# per-entry policy of marker-gated/helper-only/append-only). It is evaluated +# strictly AFTER the four legacy classes (a documented ordering, not just an +# implementation detail — see classify_protected's own docstring) and +# changes nothing about the RETURN CONTRACT: still a bare set of class-name +# strings, exactly as before. Resolving the registered path's POLICY is left +# entirely to whichever flank branch consumes the "state" tag +# (_protectedstatelib.lookup_policy / resolve_registered_path) — never done +# here, so this module gains no dependency on a POLICY concept it has no +# other reason to know about, and the four pre-existing consumers of +# classify_protected see zero contract change. from __future__ import annotations import re -from _pathnorm import norm_path, repo_rel +from _pathnorm import norm_path, raw_repo_rel, repo_rel # Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized @@ -110,6 +123,25 @@ # legitimately shell-written (the sanctioned producers are the python # security-pass.py / migration-pass.py helpers), unlike adr-authoring-active # which /adr legitimately `touch`es. +# +# T-08a / #564: this set enumerates markers whose PRESENCE converts a +# hard-gate BLOCK into an ALLOW (H-09b/H-10b's security-gate-passed, H-14's +# migration-gate-passed) — that conversion is the membership criterion, not +# "is this a marker under .markers/". A protected-state AUTHORING marker +# (e.g. release-targets-authoring, and the `-authoring` shape every +# future marker-gated consumer follows — _protectedstatelib.marker_name_for) +# does NOT belong here even though it also gates a write: it authorizes +# nothing on its own, the content it gates still passes through ordinary +# review, and it is deliberately self-mintable by an agent (a plain `touch`) +# under ADR-0010 — same as adr-authoring-active above. Its purpose is audit +# friction, not authorization. +# +# The risk here runs opposite to intuition: a future "hardening" pass that +# widens this blocklist to cover every registered marker would brick every +# legitimate authoring lane (a marker an agent can never touch admits +# nothing, ever) while stopping no non-cooperative agent (who can bypass +# this guard's own enforcement the same way they could bypass any other +# lexical shell check). See test_marker_touch_allowed (T-08a) for the pin. GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)" @@ -177,6 +209,44 @@ def is_marker_path(rel): return bool(MARKERS_RE.search(norm_path(rel))) +def is_registered_state_path(rel): + """True iff `rel` carries a policy in the protected-state registry + (`_protectedstatelib.lookup_policy`, B1/#564) — the fifth protected class + `classify_protected` recognizes (T-05a), evaluated strictly AFTER the + other four. + + The `_protectedstatelib` import is deliberately LAZY — done here, inside + the function body, rather than at this module's top level. + `_protectedstatelib` imports `marker_fresh` from `_hooklib`, and + `_hooklib` imports `classify_protected` (and every other name in this + file) FROM here — a module-level `import _protectedstatelib` at the top + of `_protectedlib.py` would be a genuine three-module import cycle + (`_protectedlib` -> `_protectedstatelib` -> `_hooklib` -> `_protectedlib`) + that Python cannot resolve at module-load time. Deferring the import to + CALL time breaks the cycle without restructuring the dependency graph: + every real caller reaches this function only after `_hooklib` (and + therefore this module) has already finished its own top-level execution, + so by the time the deferred import statement actually runs, + `_protectedstatelib`'s own `from _hooklib import marker_fresh` resolves + against an already-complete `_hooklib` module. + + Correction (finding F11, #564 follow-up): this is NOT the same pattern + `_babysitlib.babysit_config` or `statusline.py` use — neither of those + actually breaks an import CYCLE. `statusline.py`'s function-level + imports are a startup-latency lazy-load with no cyclic partner at all, + and `_babysitlib.py`'s deferred import sits inside a lazy default + injection where `_hooklib` never imports `_babysitlib` back (no cycle + to break). The deferral here IS still correct and necessary — it is + the only thing that breaks the genuine three-module cycle described + above — the prior comment simply cited the wrong precedent; corrected + so the comment matches the record. + + It is a local import, not file I/O or a git call, so it does not violate + this module's zero-side-effects-at-import invariant.""" + import _protectedstatelib + return _protectedstatelib.lookup_policy(rel) is not None + + def classify_protected(fpath, root): """The set of protected classes a Write/Edit `fpath` targets, resolving symlinks (#162). Each classifier runs against BOTH the raw normalized path @@ -184,11 +254,43 @@ def classify_protected(fpath, root): path lacks `.codearbiter/` still realpaths back inside the repo, so an alias can no longer launder a write past the guard. Centralized so pre-write.py and pre-edit.py apply the identical symlink-safe check to every class (H-05, - H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice. + H-11, #159 CONTEXT.md, #160 markers, #564 protected-state) instead of + re-encoding it twice. + + Classes: "audit", "decisions", "context", "marker", "state". repo_rel() + returns "" for a target outside the repo (which cannot be a + `.codearbiter` path), so that flank is simply skipped. - Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for - a target outside the repo (which cannot be a `.codearbiter` path), so that - flank is simply skipped.""" + "state" (T-05a, #564) is resolved in a SEPARATE loop below, strictly + AFTER the four legacy classes above — not interleaved into their loop. + That split is about incremental extension safety (a class added later + stays visibly separate from the four it was added beside, per the + module-header rationale above), NOT about the hits it produces: + `hits` is a `set`, so the SECOND loop's position relative to the first + has no observable effect on `classify_protected`'s own return value — + no test (mutant or otherwise) can distinguish "state resolved in loop 2" + from "state resolved inside loop 1" from the returned set alone + (finding F12, #564 follow-up correcting an earlier, overselling version + of this paragraph). The ordering that DOES matter or a real class/tag + conflict lives elsewhere and IS enforced in code: the textual branch + order of the `if "..." in classes:` checks in pre-write.py/pre-edit.py, + and pre-edit.py's own `_CLASS_TAG` priority tuple, which decide which + ONE message a caller emits when a path hits more than one class. + + The "state" loop tries BOTH a raw (symlink-unresolved) repo-relative + form (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`) — the SAME #162 two-form symlink-safety property the four + legacy classes get for free from `.search()`-based regex matching over + the raw path text, restored here explicitly (finding F3) because an + EQUALITY-based lookup (`_protectedstatelib.lookup_policy`) does not get + that coverage for free from a bare `norm_path(fpath)` — see + `_protectedstatelib.resolve_registered_path`'s docstring for the full + account of the symlink-inversion bug this closes. The return contract is + unchanged by this addition: still a bare set of strings. A caller that + needs the registered path's POLICY (marker-gated/helper-only/ + append-only) resolves it separately, via `_protectedstatelib` — this + function only ever reports CLASS membership, exactly as it always has, + so its four pre-existing consumers see no contract change.""" hits = set() for p in (norm_path(fpath), repo_rel(fpath, root)): if not p: @@ -201,4 +303,10 @@ def classify_protected(fpath, root): hits.add("context") if is_marker_path(p): hits.add("marker") + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + if is_registered_state_path(p): + hits.add("state") + break return hits diff --git a/plugins/ca-codex/hooks/_protectedstatelib.py b/plugins/ca-codex/hooks/_protectedstatelib.py index 82c87561..b2f93e7e 100644 --- a/plugins/ca-codex/hooks/_protectedstatelib.py +++ b/plugins/ca-codex/hooks/_protectedstatelib.py @@ -60,6 +60,21 @@ # dict, which would drop the # raw-and-realpath symlink coverage that # dispatch provides. +# resolve_registered_path(fpath, root, registry=None) +# -> (rel_path, ProtectedPolicy) | (None, +# None). The T-06/T-07 flank helper: once +# classify_protected has already reported +# "state" for `fpath`, this resolves +# WHICH registered path matched and WHICH +# policy it carries, trying both the raw +# normalized path and its +# realpath-resolved repo-relative form - +# the SAME two forms classify_protected +# itself tries (#162) - so the flank +# resolves the identical entry +# classify_protected saw rather than +# re-deriving membership through an +# independent check. # MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches # the ADR-authoring gate's value by # convention, not a shared import - see @@ -106,7 +121,7 @@ from enum import Enum from _hooklib import marker_fresh -from _pathnorm import norm_path +from _pathnorm import norm_path, raw_repo_rel, repo_rel class ProtectedPolicy(str, Enum): @@ -141,17 +156,70 @@ class ProtectedPolicy(str, Enum): REGISTRY: dict[str, ProtectedPolicy] = {} +def _canon(rel_path): + """Canonical comparison form of a repo-relative path: separator- + normalized (`norm_path`), then whitespace-stripped, `./`-prefix- + stripped (repeatable — "././x" too), doubled-slash-collapsed, + trailing-slash-stripped, and finally case-folded. + + Applied to BOTH sides of every `lookup_policy` comparison (the query + path AND every registry key) so a spelling difference on either side + degrades to "still matches" rather than "silently matches nothing" — + the same "a malformed key degrades the way a malformed query path does" + principle `lookup_policy`'s own docstring states, extended to cover the + specific spellings its docstring already promised but the OLD + norm_path-only comparison silently missed (#564 follow-up, finding F2): + a leading `./`, a trailing slash, a doubled slash, and a leading space. + + Case-folded — not merely separator-normalized — for a second, + independent reason (finding F1): `_bashguardlib.py`'s H-22 shell-flank + regexes compile with `re.I` (`_state_write_res`), so a case-sensitive + equality check here would let the two flanks disagree on whether a + differently-cased spelling of a registered path is protected. On a + case-preserving-but-insensitive filesystem (default macOS/APFS, + Windows/NTFS) that disagreement is a live fail-open: `_protectedlib. + classify_protected` resolves through `os.path.realpath`, which does + NOT canonicalize case for an EXISTING path on a case-insensitive mount + (posixpath.realpath never folds case at all; even `nt.realpath`, which + does resolve an existing file's on-disk case, cannot help a NOT-YET- + created file — exactly the Write that creates a protected-state file + for the first time) — so `Write(".codearbiter/Open-Tasks.md")` could + reach this equality check with a case that never gets folded away + before comparison. This module deliberately picks ONE fixed rule, + case-INSENSITIVE, GLOBALLY, rather than "whatever this host's + filesystem happens to do": matching host behavior is not obviously + right either (it varies per platform AND per volume on the same + platform), and a fixed global rule is the only option `_bashguardlib.py` + can mirror without itself inspecting the filesystem. Choosing + case-INSENSITIVE (not case-sensitive) only WIDENS what H-22 protects — + consistent with this codebase's "ambiguity resolves CLOSED" stance + (module comment, `_bashguardlib.py`) — at the cost of a same-directory + file whose name differs from a registered path ONLY by case (e.g. a + genuinely different `OPEN-TASKS.MD`) being treated as protected too; a + registry entry choosing a name that collides with a real sibling file + under a case change is expected to be rare enough that this is judged + the right trade.""" + p = norm_path(rel_path).strip() + while p.startswith("./"): + p = p[2:] + while "//" in p: + p = p.replace("//", "/") + p = p.rstrip("/") + return p.lower() + + def lookup_policy(rel_path, registry=None): """The ProtectedPolicy registered for `rel_path`, or None if it carries - no policy. Both `rel_path` and every registry key are separator- - normalized before comparison, so a Windows backslash path matches a - registry keyed with forward slashes (the same normalization every other - _*lib.py classifier applies via norm_path) AND a registry entry that was - itself typo'd with a backslash, a `./` prefix character, or a trailing - slash still matches rather than silently protecting nothing - a - malformed *key* degrades the same way a malformed query path does; only a - malformed *policy* (see ProtectedPolicy) is an internal error worth - raising on. + no policy. Both `rel_path` and every registry key are canonicalized + (`_canon`, above) before comparison — separator-normalized, `./`/ + doubled-slash/trailing-slash/leading-space tolerant, and + case-INSENSITIVE (deliberately, globally — see `_canon`'s docstring for + why) — so a Windows backslash path, a `./`-prefixed or trailing-slash + query, or a differently-cased spelling all match a registry entry, AND + a registry entry that was itself typo'd any of those ways still matches + rather than silently protecting nothing - a malformed *key* degrades the + same way a malformed query path does; only a malformed *policy* (see + ProtectedPolicy) is an internal error worth raising on. `registry` defaults to the module-level REGISTRY; a test (or a future caller) may pass a synthetic dict instead, which is what keeps this @@ -163,13 +231,67 @@ def lookup_policy(rel_path, registry=None): alias this module does not itself guard against.""" if registry is None: registry = REGISTRY - normalized = norm_path(rel_path) + normalized = _canon(rel_path) for key, policy in registry.items(): - if norm_path(key) == normalized: + if _canon(key) == normalized: return policy return None +def resolve_registered_path(fpath, root, registry=None): + """The `(rel_path, policy)` pair a Write/Edit/shell target resolves to, + once `_protectedlib.classify_protected` has already reported "state" for + it - or `(None, None)` if it turns out to carry no registry entry after + all (a caller that checks this before ever consulting + classify_protected, or a stale class set). + + Tries BOTH the raw (symlink-unresolved) repo-relative form + (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`), in that order - the SAME two-form symlink-safety property + (#162) classify_protected's four legacy classes get automatically from + running a regex `.search()` over the raw normalized path text. + + That automatic coverage does NOT transfer for free to this module's + EQUALITY-based lookup (finding F3, #564 follow-up): `norm_path(fpath)` - + almost always an ABSOLUTE path, since every host sends one - is never + equal to a repo-relative registry key, so trying it as the "raw" leg was + inert (it could never match anything). Worse, it made symlink coverage + the WRONG WAY ROUND versus the other four classes: when the REGISTERED + PATH ITSELF is a symlink pointing somewhere unregistered, + `os.path.realpath` resolves the ONLY spelling a host actually sends + (the absolute path) straight through the symlink to that unregistered + target, and the dead raw leg supplied no alternative route back to the + registered name — so the write was silently ADMITTED, the opposite of + the legacy classes' behavior in the equivalent scenario (a regex + `.search()` still matches the raw path text regardless of where it + realpaths to). `raw_repo_rel` fixes this: computed by pure lexical + arithmetic against `root` (no `os.path.realpath` call), it still names + the registered entry syntactically even when the path is a symlink, so + that spelling now resolves correctly too - restoring the SAME + "protected either way you spell it" guarantee the legacy classes + already had; the realpath leg still exists for the mirror-image case + (a symlinked DIRECTORY whose visible path lacks the registered prefix + but resolves into it). + + So a flank reaching this function resolves the IDENTICAL entry + classify_protected saw, rather than re-deriving membership through an + independent check. That independent-check shape is exactly what #564's + design forbids ("no second, parallel lookup") - this function only ever + RESOLVES what classify_protected already decided; it never decides + membership on its own account. + + `registry` defaults to the module-level REGISTRY, matching + `lookup_policy`'s own parameter shape, for the same reason: a test (or a + future caller) may pass a synthetic dict.""" + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + policy = lookup_policy(p, registry) + if policy is not None: + return p, policy + return None, None + + # The H-11 authoring-marker freshness window, matching the existing # ADR-authoring gate's value by convention, NOT by a shared import: # pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each diff --git a/plugins/ca-codex/hooks/pre-edit.py b/plugins/ca-codex/hooks/pre-edit.py index e08a2ca1..24eb2067 100644 --- a/plugins/ca-codex/hooks/pre-edit.py +++ b/plugins/ca-codex/hooks/pre-edit.py @@ -11,6 +11,10 @@ # be the file's REAL current trailing content, new_string must extend # it, and replace_all is rejected outright (reliability-003, #172). # H-11 ADRs under decisions/ are edited only via /adr. +# H-22 the protected-state registry (B1/#564): the SAME generic branch as +# pre-write.py — marker-gated admits only under a fresh authoring +# marker; helper-only and append-only are hard-blocked, no marker +# path at all. # # NotebookEdit is guarded too (a notebook has no append/frontmatter semantics, so # a protected target is refused outright) — defense in depth; none of the @@ -38,6 +42,9 @@ get_host, is_tail_append, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _read_real_text(root, fpath): @@ -93,7 +100,8 @@ def _run(root): # The tag that names each protected class, in message priority order. _CLASS_TAG = (("marker", "H-19"), ("context", "H-18"), - ("audit", "H-05"), ("decisions", "H-11")) + ("audit", "H-05"), ("decisions", "H-11"), + ("state", "H-22")) # H-21: an "opaque" op is a host's signal that it could not map this # payload to a known per-file shape at all (e.g. a FailClosedHost, or a @@ -207,6 +215,29 @@ def _run(root): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — the SAME generic branch + # pre-write.py carries (T-06/T-07 design ruling: one branch, not a + # per-tool special case). classify_protected already decided "state" is + # a hit; resolve_registered_path only resolves WHICH registered path and + # WHICH policy — never a second, independent membership check. + # marker-gated admits only under a fresh authoring marker; helper-only + # and append-only are flank-IDENTICAL here — both hard-block + # unconditionally, with NO marker path and no tail-anchored-append + # admission (unlike H-05's audit logs, an append-only registry entry's + # append verb lives in the sanctioned HELPER, not in this Edit flank). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — an " + f"Edit is admitted only under a fresh authoring marker. Run the " + f"sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Edit tools may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + sys.exit(0) diff --git a/plugins/ca-codex/hooks/pre-write.py b/plugins/ca-codex/hooks/pre-write.py index d0ec3088..b57c5747 100644 --- a/plugins/ca-codex/hooks/pre-write.py +++ b/plugins/ca-codex/hooks/pre-write.py @@ -11,6 +11,12 @@ # BLOCK into an allow (#160) — never writable via the Write tool. # H-05 audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) are append-only. # H-11 ADRs under decisions/ are authored only via /adr. +# H-22 the protected-state registry (B1/#564): a marker-gated entry admits +# only under a fresh authoring marker; a helper-only or append-only +# entry is hard-blocked with NO marker path at all — one generic +# branch resolves the entry's policy via _protectedstatelib and +# applies it, so a fourth registered file needs a registry entry, +# never a new hook branch. # # Every protected-path decision resolves symlinks (#162): classify_protected() # checks the raw path AND its realpath-resolved repo-relative form, so a symlink @@ -28,6 +34,9 @@ arbiter_active, block, classify_protected, frontmatter_enabled_text, get_host, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _guard_op(root, op): @@ -116,6 +125,29 @@ def _guard_op(root, op): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — ONE generic branch, + # regardless of `kind`. classify_protected already decided "state" is a + # hit; resolve_registered_path only resolves WHICH registered path + # matched and WHICH policy it carries — never a second, independent + # membership check (T-06 design ruling). marker-gated admits only under a + # fresh authoring marker; helper-only and append-only are + # flank-IDENTICAL here — both hard-block unconditionally, with NO marker + # path at all. The policies differ only in what the SANCTIONED HELPER's + # own file I/O is allowed to do, which this tool-call flank never sees by + # construction (its argv/content never reaches this guard). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — a " + f"Write is admitted only under a fresh authoring marker. Run " + f"the sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Write tool may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + def _run(root): # The host seam (ADR-0011, M2): iter_file_ops maps this host's native diff --git a/plugins/ca-pi/hooks/_bashguardlib.py b/plugins/ca-pi/hooks/_bashguardlib.py index 76907382..ed625c5c 100644 --- a/plugins/ca-pi/hooks/_bashguardlib.py +++ b/plugins/ca-pi/hooks/_bashguardlib.py @@ -57,6 +57,23 @@ # pre-bash.py — .github/scripts/ # test_hook_guards.py imports it by # this exact name) +# _state_write_res(basename) -> (redirect_re, write_re, git_restore_re, +# interp_re) H-22's per-entry +# shell-flank regex TEMPLATE for one +# protected-state registry entry's +# bare filename (T-08, #564; the +# git-restore and interpreter legs +# added per findings F5/F6) +# _build_state_write_res(registry) -> tuple[(rel_path, policy, redirect_re, +# write_re, git_restore_re, +# interp_re), ...] the compiled set +# for every entry in `registry`; +# `_STATE_WRITE_RES` is this, built +# once at import from the live +# `_protectedstatelib.REGISTRY` +# _check_h22_state(cmd, root) -> None H-22's run_guards() gate — block()s +# on a shell mutation of a registered +# protected-state file import os import re @@ -71,6 +88,12 @@ from _gitexec import git_executable import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE / # gitdir: pointer) project_root() climb (#223) +import _protectedstatelib # H-22's shell flank (T-08, #564) — imported as a + # module (not `from ... import REGISTRY`) so + # _STATE_WRITE_RES below is built from a live + # attribute lookup at import time, never a + # snapshotted name binding. +from _protectedstatelib import ProtectedPolicy, marker_gated_write_admitted # The most recent git-read failure, surfaced in the H-01/H-09b/H-14 fail-closed # block message. "git unavailable or timed out" alone cost a session of root- @@ -414,6 +437,149 @@ def _commit_no_verify_in_cluster(args): r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + GATE_MARKER, re.I, ) +# H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are +# guarded via classify_protected's "state" class (pre-write.py/pre-edit.py, +# T-05a/T-06/T-07); this closes the shell flank the SAME way the four +# pre-existing classes already do above: a redirect into a registered file, +# or a write/delete verb naming it, blocks. +# +# ONE (redirect_re, write_re) pair PER REGISTRY ENTRY, not a single +# alternation over every registered basename (T-08 design ruling): a regex +# defect in one entry's pattern then stays isolated to that entry — and is +# pinned by that entry's own test — instead of one opaque pattern smearing a +# failure across every consumer. `_state_write_res` is the per-entry +# TEMPLATE, called once per registry entry by `_build_state_write_res`. +# +# BARE basename anchor, deliberately WITHOUT a `.codearbiter/` directory +# prefix the way CONTEXT_MD carries one: the redirect-operator prefix +# (`>>?\|?\s*\S*`) and the verb-then-bounded-window shape +# (`\b(verb-list)\b[^|;&]*`) below are copied verbatim from +# CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE (above, ~line 355), but the NAME +# fragment itself mirrors AUDIT_LOG_NAMES/LOG_TRUNC_RE/LOG_DESTROY_RE's +# bare-filename anchor instead. This is not a stylistic choice: B-07/B-08's +# own non-regression cases, and the T-08b lexical residual, both name the +# protected file with NO `.codearbiter/` prefix at all — `taskwrite add -- +# "fix open-tasks.md schema"` and the false-blocking `taskwrite add -- "…tee +# open-tasks.md"` both carry the bare filename inside free-text argv. A +# directory-anchored pattern could neither reproduce the documented residual +# nor catch a real `tee open-tasks.md` run with a cwd already inside +# `.codearbiter/` (where no directory prefix appears in the command text +# either) — it would under-scan exactly the attack this guard exists for. +# The right-edge lookahead DECISION_LOG_SHELL_RE (#528, ~line 355) already +# carries. H-22's bare-basename anchor inherited the over-match closing it +# fixes without it: `rm .codearbiter/open-tasks.md.bak` matched, because the +# basename is a literal PREFIX-substring of the ".bak" spelling and nothing +# required the basename text to END where it should. Requires end-of-string, +# whitespace, a redirect/pipe/separator, or a quote-close immediately after +# the basename — never a bare `\b` word boundary alone, which cannot do this +# job here (a hyphen is a non-word character on BOTH its sides, so `\b` sits +# at a hyphen exactly as readily as at a `/`; it cannot distinguish +# "…/open-tasks.md" from "my-open-tasks.md"). +# +# This closes only the RIGHT-side over-match. The mirror-image LEFT-side one +# (a longer filename that happens to END with the registered basename, e.g. +# `my-open-tasks.md`, `> my-open-tasks.md`) is a KNOWN, ACCEPTED residual of +# the bare-basename anchor design itself (finding F4, #564 follow-up) — a +# left anchor would require knowing the basename is not itself part of a +# longer name, which the bare-anchor design (see the module comment above) +# deliberately does not have enough context to tell apart from a legitimate +# no-directory-prefix spelling. Declared, not merely implicit: see +# security-controls.md's "Protected-state registry (H-22)" section. +_STATE_NAME_RIGHT_EDGE = r"""(?=$|[\s>|;&"'])""" + +# The write-verb list, extended past the CONTEXT_WRITE_RE/DECISIONS_WRITE_RE +# baseline it was copied from (finding F6, #564 follow-up) with verbs +# present in this file's own cited precedents but missing here: `sponge` +# (already in LOG_DESTROY_RE, ~line 317), plus `ln` (a hardlinked/symlinked +# name overwrites whatever sits there with `ln -f`), `install` (coreutils' +# copy-with-permissions — a genuine overwrite verb), `patch` (rewrites a +# file in place from a diff), and `shred` (secure-delete, the ultimate +# destroy). `install`/`ln` both carry a real false-positive cost of their +# own (`npm install`/`pip install` are common phrases; `ln` is a short, +# common token) — accepted under the SAME "ambiguity resolves CLOSED" +# stance this file states at its own top (module docstring) and applies +# throughout (e.g. `cp overrides.log backup`, a mere READ, blocks anyway); +# declared in security-controls.md rather than left an undeclared gap. +_STATE_WRITE_VERBS = ( + r"rm|del|mv|cp|copy|dd|tee|sed|sponge|ln|install|patch|shred|truncate|ni" + r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content" + r"|Out-File|Add-Content" +) + + +def _state_write_res(basename): + r"""`(redirect_re, write_re, git_restore_re, interp_re)` for ONE + protected-state registry entry's bare filename — the compiled set + `_build_state_write_res` returns one of, per entry. See the module + comment above for why this is bare-basename, not directory-anchored, + and `_STATE_NAME_RIGHT_EDGE`/`_STATE_WRITE_VERBS` above for the + right-anchor and extended verb list (finding F4/F6). + + `git_restore_re` (finding F5, #564 follow-up): mirrors H-05's + LOG_GIT_RESTORE_RE (#335) — `git checkout`/`git restore` rewrite a + TRACKED worktree file through git itself, bypassing every filesystem + verb above entirely (all three planned registry entries are tracked + files, so this is not a hypothetical). A SEPARATE pattern, not folded + into the write-verb list: `checkout`/`restore` are git SUBCOMMANDS, not + shell verbs, and matching them needs the `GIT` global-options-tolerant + prefix the write-verb list has no business carrying. Deliberately does + NOT match `git add` — B-07's non-regression (commit-gate Phase 7 runs + `git add open-tasks.md` on every retained board flip, which must never + trip H-22) — and structurally cannot: the subcommand alternation here is + only `checkout|restore`. + + `interp_re` (finding F6, #564 follow-up): mirrors GATE_MARKER_INTERP_RE + (#237) — an arbitrary interpreter one-liner + (`python -c "open('open-tasks.md','w')..."`) reuses `helper-only`'s own + sanctioned Python file-I/O route while naming the target file lexically, + a flank no verb-list spelling above can see. `[\s\S]*` (not `[^\n]*`, + per the #237 follow-up) so the interpreter token and the filename may + sit on different physical lines of the SAME multi-line `-c`/`-e` + payload — `[^\n]*` cannot cross that newline and would silently reopen + the identical hole in its multi-line spelling.""" + name = re.escape(basename) + redirect_re = re.compile( + r">>?\|?\s*\S*" + name + _STATE_NAME_RIGHT_EDGE, re.I) + write_re = re.compile( + r"\b(" + _STATE_WRITE_VERBS + r")\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + git_restore_re = re.compile( + GIT + r"\s+(?:checkout|restore)\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + interp_re = re.compile( + r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + return redirect_re, write_re, git_restore_re, interp_re + + +def _build_state_write_res(registry): + """`(rel_path, policy, redirect_re, write_re, git_restore_re, + interp_re)` for every entry in `registry`, keyed on each entry's bare + basename via `_state_write_res`. An explicit `registry` PARAMETER (not a + bare comprehension over the module-level default) so a test can rebuild + this exact tuple against a SYNTHETIC registry — the real one ships EMPTY + at this slice (T-01–T-08; T-33/T-65/T-66 enroll the three named + consumers later) — the same `registry=`-parameter shape + `_protectedstatelib.lookup_policy` already uses for the identical + reason.""" + built = [] + for rel_path, policy in registry.items(): + basename = rel_path.replace("\\", "/").rsplit("/", 1)[-1] + redirect_re, write_re, git_restore_re, interp_re = _state_write_res(basename) + built.append((rel_path, policy, redirect_re, write_re, git_restore_re, interp_re)) + return tuple(built) + + +# performance-002/_scopelib.py:109-117 precedent: compiled ONCE at import +# from the live (code-constant, never disk-loaded — #564 design ruling) +# registry, not recompiled per call. Empty at this slice, so +# `_check_h22_state` is correctly a no-op against every command until a +# consumer is registered. A test exercises the real logic by rebuilding this +# EXACT tuple against a synthetic registry (`_build_state_write_res`), never +# by mutating `_protectedstatelib.REGISTRY` after the fact — this tuple +# would not see that (it is a one-time import-time snapshot, by design). +_STATE_WRITE_RES = _build_state_write_res(_protectedstatelib.REGISTRY) + def git_cwd(cmd, root): """The directory a `git -C ` invocation actually targets, or `root` @@ -1042,6 +1208,50 @@ def _gate_marker_hit(view): "prohibited.") +def _check_h22_state(cmd, root): + """H-22: the protected-state registry's shell flank (B-04/B1, #564). + Walks the precompiled per-entry pairs (`_STATE_WRITE_RES`); a command + that redirects into, or runs a write/delete verb against, a registered + basename either admits (marker-gated, under a FRESH authoring marker — + `_protectedstatelib.marker_gated_write_admitted`, the H-11 pattern) or + blocks outright (helper-only/append-only — flank-IDENTICAL: the + distinction between them lives entirely in what the sanctioned helper's + OWN append verb is allowed to do, never in this shell guard, which has + no marker path for either). + + Marker checks read from the pinned `root` (project_root), never `cwd` — + the SAME split `_check_h09b_h10b_crypto_secret`/`_check_h14_migration` + already draw (D-2, `_effective_exec_root`'s own docstring): a linked + worktree has `.codearbiter/` (tracked) but not `.codearbiter/.markers/` + (gitignored), so marker paths must stay anchored at the main checkout. + + Git verbs are deliberately ABSENT from the write-verb list (the same + list `CONTEXT_WRITE_RE`/`DECISIONS_WRITE_RE` already use) — `git add + open-tasks.md` (commit-gate Phase 7, run on every retained board flip) + must never reach a block here (B-07), or commit-gate would block itself + on its own sanctioned board-flip staging. `git checkout`/`git restore` + ARE covered, but via the separate `git_restore_re` leg (finding F5, + #564 follow-up) — never the general write-verb list — precisely so that + isolation holds: `git_restore_re`'s subcommand alternation is only + `checkout|restore`, so it structurally cannot also catch `git add`.""" + for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in _STATE_WRITE_RES: + if not (redirect_re.search(cmd) or write_re.search(cmd) + or git_restore_re.search(cmd) or interp_re.search(cmd)): + continue + if policy == ProtectedPolicy.MARKER_GATED and marker_gated_write_admitted(rel_path, root): + continue + if policy == ProtectedPolicy.MARKER_GATED: + block("H-22", f"'{rel_path}' is marker-gated protected project state (#564) — a " + f"shell redirect or write/delete verb naming it is admitted only " + f"under a fresh authoring marker. Mint the marker via the sanctioned " + f"authoring lane, or /override.") + else: + block("H-22", f"'{rel_path}' is protected project state (#564, " + f"policy={policy.value}) — shell redirects and write/delete verbs " + f"naming it are prohibited outright; there is no marker path for " + f"this policy. Use the sanctioned helper.") + + def _check_h09b_h10b_crypto_secret(commit, add, cwd, root): """H-09b / H-10b: BLOCK a commit that introduces crypto/secret changes without a recorded security-gate pass. The crypto-compliance / secret-handling skills @@ -1229,6 +1439,7 @@ def run_guards(payload, root, ti): _check_h11_decisions(cmd) _check_h18_context_md(cmd) _check_h19_gate_marker(git_view, cmd, heredoc_shell_fallback) + _check_h22_state(cmd, root) _check_h09b_h10b_crypto_secret(commit, add, cwd, root) _check_h14_migration(commit, add, cwd, root) diff --git a/plugins/ca-pi/hooks/_pathnorm.py b/plugins/ca-pi/hooks/_pathnorm.py index c63dc6ae..a4ca0fcc 100644 --- a/plugins/ca-pi/hooks/_pathnorm.py +++ b/plugins/ca-pi/hooks/_pathnorm.py @@ -42,3 +42,33 @@ def repo_rel(fpath, root): rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root)) rel = rel.replace(os.sep, "/") return "" if rel == ".." or rel.startswith("../") else rel + + +def raw_repo_rel(fpath, root): + """`repo_rel`'s realpath-FREE twin: the repo-relative POSIX path for + `fpath` computed by pure lexical arithmetic against `root`, resolving + NEITHER side's symlinks — the raw spelling a host actually sent, or "" + when it cannot be expressed relative to `root` at all (outside root, or + on Windows a different drive). + + Exists for the classifiers that need BOTH the realpath-resolved form + AND the unresolved one to reproduce the #162 symlink-safety property + (`classify_protected` already tries a raw and a realpath'd form for + every legacy class, via regex `.search()` on the raw normalized string — + a shape that happens to also cover "the protected path itself is a + symlink" for those classes, because the pattern matches the raw text + regardless of what it resolves to). The equality-based `state` class + (`_protectedstatelib.lookup_policy`) has no equivalent for-free coverage + from a bare `norm_path(fpath)` — that string is very often absolute, + never equal to a repo-relative registry key — so a caller needing the + same raw-spelling coverage for an EQUALITY-based lookup should resolve + `fpath` against `root` here first, exactly like `repo_rel` does, just + without the `os.path.realpath()` call (T-06/F3, #564 follow-up).""" + if not fpath: + return "" + try: + rel = os.path.relpath(os.path.normpath(fpath), os.path.normpath(root)) + except ValueError: + return "" # Windows: fpath and root on different drives + rel = rel.replace(os.sep, "/") + return "" if rel == ".." or rel.startswith("../") else rel diff --git a/plugins/ca-pi/hooks/_protectedlib.py b/plugins/ca-pi/hooks/_protectedlib.py index d71d770a..e5ca02a2 100644 --- a/plugins/ca-pi/hooks/_protectedlib.py +++ b/plugins/ca-pi/hooks/_protectedlib.py @@ -20,12 +20,25 @@ # # _hooklib re-exports every name below, so no consumer changed and the # pre-existing hook suites prove parity without moving. +# +# T-05a (#564): a FIFTH class, "state", joins the four above — a path +# registered in the protected-state registry (_protectedstatelib.py, B1: a +# per-entry policy of marker-gated/helper-only/append-only). It is evaluated +# strictly AFTER the four legacy classes (a documented ordering, not just an +# implementation detail — see classify_protected's own docstring) and +# changes nothing about the RETURN CONTRACT: still a bare set of class-name +# strings, exactly as before. Resolving the registered path's POLICY is left +# entirely to whichever flank branch consumes the "state" tag +# (_protectedstatelib.lookup_policy / resolve_registered_path) — never done +# here, so this module gains no dependency on a POLICY concept it has no +# other reason to know about, and the four pre-existing consumers of +# classify_protected see zero contract change. from __future__ import annotations import re -from _pathnorm import norm_path, repo_rel +from _pathnorm import norm_path, raw_repo_rel, repo_rel # Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized @@ -110,6 +123,25 @@ # legitimately shell-written (the sanctioned producers are the python # security-pass.py / migration-pass.py helpers), unlike adr-authoring-active # which /adr legitimately `touch`es. +# +# T-08a / #564: this set enumerates markers whose PRESENCE converts a +# hard-gate BLOCK into an ALLOW (H-09b/H-10b's security-gate-passed, H-14's +# migration-gate-passed) — that conversion is the membership criterion, not +# "is this a marker under .markers/". A protected-state AUTHORING marker +# (e.g. release-targets-authoring, and the `-authoring` shape every +# future marker-gated consumer follows — _protectedstatelib.marker_name_for) +# does NOT belong here even though it also gates a write: it authorizes +# nothing on its own, the content it gates still passes through ordinary +# review, and it is deliberately self-mintable by an agent (a plain `touch`) +# under ADR-0010 — same as adr-authoring-active above. Its purpose is audit +# friction, not authorization. +# +# The risk here runs opposite to intuition: a future "hardening" pass that +# widens this blocklist to cover every registered marker would brick every +# legitimate authoring lane (a marker an agent can never touch admits +# nothing, ever) while stopping no non-cooperative agent (who can bypass +# this guard's own enforcement the same way they could bypass any other +# lexical shell check). See test_marker_touch_allowed (T-08a) for the pin. GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)" @@ -177,6 +209,44 @@ def is_marker_path(rel): return bool(MARKERS_RE.search(norm_path(rel))) +def is_registered_state_path(rel): + """True iff `rel` carries a policy in the protected-state registry + (`_protectedstatelib.lookup_policy`, B1/#564) — the fifth protected class + `classify_protected` recognizes (T-05a), evaluated strictly AFTER the + other four. + + The `_protectedstatelib` import is deliberately LAZY — done here, inside + the function body, rather than at this module's top level. + `_protectedstatelib` imports `marker_fresh` from `_hooklib`, and + `_hooklib` imports `classify_protected` (and every other name in this + file) FROM here — a module-level `import _protectedstatelib` at the top + of `_protectedlib.py` would be a genuine three-module import cycle + (`_protectedlib` -> `_protectedstatelib` -> `_hooklib` -> `_protectedlib`) + that Python cannot resolve at module-load time. Deferring the import to + CALL time breaks the cycle without restructuring the dependency graph: + every real caller reaches this function only after `_hooklib` (and + therefore this module) has already finished its own top-level execution, + so by the time the deferred import statement actually runs, + `_protectedstatelib`'s own `from _hooklib import marker_fresh` resolves + against an already-complete `_hooklib` module. + + Correction (finding F11, #564 follow-up): this is NOT the same pattern + `_babysitlib.babysit_config` or `statusline.py` use — neither of those + actually breaks an import CYCLE. `statusline.py`'s function-level + imports are a startup-latency lazy-load with no cyclic partner at all, + and `_babysitlib.py`'s deferred import sits inside a lazy default + injection where `_hooklib` never imports `_babysitlib` back (no cycle + to break). The deferral here IS still correct and necessary — it is + the only thing that breaks the genuine three-module cycle described + above — the prior comment simply cited the wrong precedent; corrected + so the comment matches the record. + + It is a local import, not file I/O or a git call, so it does not violate + this module's zero-side-effects-at-import invariant.""" + import _protectedstatelib + return _protectedstatelib.lookup_policy(rel) is not None + + def classify_protected(fpath, root): """The set of protected classes a Write/Edit `fpath` targets, resolving symlinks (#162). Each classifier runs against BOTH the raw normalized path @@ -184,11 +254,43 @@ def classify_protected(fpath, root): path lacks `.codearbiter/` still realpaths back inside the repo, so an alias can no longer launder a write past the guard. Centralized so pre-write.py and pre-edit.py apply the identical symlink-safe check to every class (H-05, - H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice. + H-11, #159 CONTEXT.md, #160 markers, #564 protected-state) instead of + re-encoding it twice. + + Classes: "audit", "decisions", "context", "marker", "state". repo_rel() + returns "" for a target outside the repo (which cannot be a + `.codearbiter` path), so that flank is simply skipped. - Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for - a target outside the repo (which cannot be a `.codearbiter` path), so that - flank is simply skipped.""" + "state" (T-05a, #564) is resolved in a SEPARATE loop below, strictly + AFTER the four legacy classes above — not interleaved into their loop. + That split is about incremental extension safety (a class added later + stays visibly separate from the four it was added beside, per the + module-header rationale above), NOT about the hits it produces: + `hits` is a `set`, so the SECOND loop's position relative to the first + has no observable effect on `classify_protected`'s own return value — + no test (mutant or otherwise) can distinguish "state resolved in loop 2" + from "state resolved inside loop 1" from the returned set alone + (finding F12, #564 follow-up correcting an earlier, overselling version + of this paragraph). The ordering that DOES matter or a real class/tag + conflict lives elsewhere and IS enforced in code: the textual branch + order of the `if "..." in classes:` checks in pre-write.py/pre-edit.py, + and pre-edit.py's own `_CLASS_TAG` priority tuple, which decide which + ONE message a caller emits when a path hits more than one class. + + The "state" loop tries BOTH a raw (symlink-unresolved) repo-relative + form (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`) — the SAME #162 two-form symlink-safety property the four + legacy classes get for free from `.search()`-based regex matching over + the raw path text, restored here explicitly (finding F3) because an + EQUALITY-based lookup (`_protectedstatelib.lookup_policy`) does not get + that coverage for free from a bare `norm_path(fpath)` — see + `_protectedstatelib.resolve_registered_path`'s docstring for the full + account of the symlink-inversion bug this closes. The return contract is + unchanged by this addition: still a bare set of strings. A caller that + needs the registered path's POLICY (marker-gated/helper-only/ + append-only) resolves it separately, via `_protectedstatelib` — this + function only ever reports CLASS membership, exactly as it always has, + so its four pre-existing consumers see no contract change.""" hits = set() for p in (norm_path(fpath), repo_rel(fpath, root)): if not p: @@ -201,4 +303,10 @@ def classify_protected(fpath, root): hits.add("context") if is_marker_path(p): hits.add("marker") + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + if is_registered_state_path(p): + hits.add("state") + break return hits diff --git a/plugins/ca-pi/hooks/_protectedstatelib.py b/plugins/ca-pi/hooks/_protectedstatelib.py index 82c87561..b2f93e7e 100644 --- a/plugins/ca-pi/hooks/_protectedstatelib.py +++ b/plugins/ca-pi/hooks/_protectedstatelib.py @@ -60,6 +60,21 @@ # dict, which would drop the # raw-and-realpath symlink coverage that # dispatch provides. +# resolve_registered_path(fpath, root, registry=None) +# -> (rel_path, ProtectedPolicy) | (None, +# None). The T-06/T-07 flank helper: once +# classify_protected has already reported +# "state" for `fpath`, this resolves +# WHICH registered path matched and WHICH +# policy it carries, trying both the raw +# normalized path and its +# realpath-resolved repo-relative form - +# the SAME two forms classify_protected +# itself tries (#162) - so the flank +# resolves the identical entry +# classify_protected saw rather than +# re-deriving membership through an +# independent check. # MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches # the ADR-authoring gate's value by # convention, not a shared import - see @@ -106,7 +121,7 @@ from enum import Enum from _hooklib import marker_fresh -from _pathnorm import norm_path +from _pathnorm import norm_path, raw_repo_rel, repo_rel class ProtectedPolicy(str, Enum): @@ -141,17 +156,70 @@ class ProtectedPolicy(str, Enum): REGISTRY: dict[str, ProtectedPolicy] = {} +def _canon(rel_path): + """Canonical comparison form of a repo-relative path: separator- + normalized (`norm_path`), then whitespace-stripped, `./`-prefix- + stripped (repeatable — "././x" too), doubled-slash-collapsed, + trailing-slash-stripped, and finally case-folded. + + Applied to BOTH sides of every `lookup_policy` comparison (the query + path AND every registry key) so a spelling difference on either side + degrades to "still matches" rather than "silently matches nothing" — + the same "a malformed key degrades the way a malformed query path does" + principle `lookup_policy`'s own docstring states, extended to cover the + specific spellings its docstring already promised but the OLD + norm_path-only comparison silently missed (#564 follow-up, finding F2): + a leading `./`, a trailing slash, a doubled slash, and a leading space. + + Case-folded — not merely separator-normalized — for a second, + independent reason (finding F1): `_bashguardlib.py`'s H-22 shell-flank + regexes compile with `re.I` (`_state_write_res`), so a case-sensitive + equality check here would let the two flanks disagree on whether a + differently-cased spelling of a registered path is protected. On a + case-preserving-but-insensitive filesystem (default macOS/APFS, + Windows/NTFS) that disagreement is a live fail-open: `_protectedlib. + classify_protected` resolves through `os.path.realpath`, which does + NOT canonicalize case for an EXISTING path on a case-insensitive mount + (posixpath.realpath never folds case at all; even `nt.realpath`, which + does resolve an existing file's on-disk case, cannot help a NOT-YET- + created file — exactly the Write that creates a protected-state file + for the first time) — so `Write(".codearbiter/Open-Tasks.md")` could + reach this equality check with a case that never gets folded away + before comparison. This module deliberately picks ONE fixed rule, + case-INSENSITIVE, GLOBALLY, rather than "whatever this host's + filesystem happens to do": matching host behavior is not obviously + right either (it varies per platform AND per volume on the same + platform), and a fixed global rule is the only option `_bashguardlib.py` + can mirror without itself inspecting the filesystem. Choosing + case-INSENSITIVE (not case-sensitive) only WIDENS what H-22 protects — + consistent with this codebase's "ambiguity resolves CLOSED" stance + (module comment, `_bashguardlib.py`) — at the cost of a same-directory + file whose name differs from a registered path ONLY by case (e.g. a + genuinely different `OPEN-TASKS.MD`) being treated as protected too; a + registry entry choosing a name that collides with a real sibling file + under a case change is expected to be rare enough that this is judged + the right trade.""" + p = norm_path(rel_path).strip() + while p.startswith("./"): + p = p[2:] + while "//" in p: + p = p.replace("//", "/") + p = p.rstrip("/") + return p.lower() + + def lookup_policy(rel_path, registry=None): """The ProtectedPolicy registered for `rel_path`, or None if it carries - no policy. Both `rel_path` and every registry key are separator- - normalized before comparison, so a Windows backslash path matches a - registry keyed with forward slashes (the same normalization every other - _*lib.py classifier applies via norm_path) AND a registry entry that was - itself typo'd with a backslash, a `./` prefix character, or a trailing - slash still matches rather than silently protecting nothing - a - malformed *key* degrades the same way a malformed query path does; only a - malformed *policy* (see ProtectedPolicy) is an internal error worth - raising on. + no policy. Both `rel_path` and every registry key are canonicalized + (`_canon`, above) before comparison — separator-normalized, `./`/ + doubled-slash/trailing-slash/leading-space tolerant, and + case-INSENSITIVE (deliberately, globally — see `_canon`'s docstring for + why) — so a Windows backslash path, a `./`-prefixed or trailing-slash + query, or a differently-cased spelling all match a registry entry, AND + a registry entry that was itself typo'd any of those ways still matches + rather than silently protecting nothing - a malformed *key* degrades the + same way a malformed query path does; only a malformed *policy* (see + ProtectedPolicy) is an internal error worth raising on. `registry` defaults to the module-level REGISTRY; a test (or a future caller) may pass a synthetic dict instead, which is what keeps this @@ -163,13 +231,67 @@ def lookup_policy(rel_path, registry=None): alias this module does not itself guard against.""" if registry is None: registry = REGISTRY - normalized = norm_path(rel_path) + normalized = _canon(rel_path) for key, policy in registry.items(): - if norm_path(key) == normalized: + if _canon(key) == normalized: return policy return None +def resolve_registered_path(fpath, root, registry=None): + """The `(rel_path, policy)` pair a Write/Edit/shell target resolves to, + once `_protectedlib.classify_protected` has already reported "state" for + it - or `(None, None)` if it turns out to carry no registry entry after + all (a caller that checks this before ever consulting + classify_protected, or a stale class set). + + Tries BOTH the raw (symlink-unresolved) repo-relative form + (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`), in that order - the SAME two-form symlink-safety property + (#162) classify_protected's four legacy classes get automatically from + running a regex `.search()` over the raw normalized path text. + + That automatic coverage does NOT transfer for free to this module's + EQUALITY-based lookup (finding F3, #564 follow-up): `norm_path(fpath)` - + almost always an ABSOLUTE path, since every host sends one - is never + equal to a repo-relative registry key, so trying it as the "raw" leg was + inert (it could never match anything). Worse, it made symlink coverage + the WRONG WAY ROUND versus the other four classes: when the REGISTERED + PATH ITSELF is a symlink pointing somewhere unregistered, + `os.path.realpath` resolves the ONLY spelling a host actually sends + (the absolute path) straight through the symlink to that unregistered + target, and the dead raw leg supplied no alternative route back to the + registered name — so the write was silently ADMITTED, the opposite of + the legacy classes' behavior in the equivalent scenario (a regex + `.search()` still matches the raw path text regardless of where it + realpaths to). `raw_repo_rel` fixes this: computed by pure lexical + arithmetic against `root` (no `os.path.realpath` call), it still names + the registered entry syntactically even when the path is a symlink, so + that spelling now resolves correctly too - restoring the SAME + "protected either way you spell it" guarantee the legacy classes + already had; the realpath leg still exists for the mirror-image case + (a symlinked DIRECTORY whose visible path lacks the registered prefix + but resolves into it). + + So a flank reaching this function resolves the IDENTICAL entry + classify_protected saw, rather than re-deriving membership through an + independent check. That independent-check shape is exactly what #564's + design forbids ("no second, parallel lookup") - this function only ever + RESOLVES what classify_protected already decided; it never decides + membership on its own account. + + `registry` defaults to the module-level REGISTRY, matching + `lookup_policy`'s own parameter shape, for the same reason: a test (or a + future caller) may pass a synthetic dict.""" + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + policy = lookup_policy(p, registry) + if policy is not None: + return p, policy + return None, None + + # The H-11 authoring-marker freshness window, matching the existing # ADR-authoring gate's value by convention, NOT by a shared import: # pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each diff --git a/plugins/ca-pi/hooks/pre-edit.py b/plugins/ca-pi/hooks/pre-edit.py index e08a2ca1..24eb2067 100644 --- a/plugins/ca-pi/hooks/pre-edit.py +++ b/plugins/ca-pi/hooks/pre-edit.py @@ -11,6 +11,10 @@ # be the file's REAL current trailing content, new_string must extend # it, and replace_all is rejected outright (reliability-003, #172). # H-11 ADRs under decisions/ are edited only via /adr. +# H-22 the protected-state registry (B1/#564): the SAME generic branch as +# pre-write.py — marker-gated admits only under a fresh authoring +# marker; helper-only and append-only are hard-blocked, no marker +# path at all. # # NotebookEdit is guarded too (a notebook has no append/frontmatter semantics, so # a protected target is refused outright) — defense in depth; none of the @@ -38,6 +42,9 @@ get_host, is_tail_append, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _read_real_text(root, fpath): @@ -93,7 +100,8 @@ def _run(root): # The tag that names each protected class, in message priority order. _CLASS_TAG = (("marker", "H-19"), ("context", "H-18"), - ("audit", "H-05"), ("decisions", "H-11")) + ("audit", "H-05"), ("decisions", "H-11"), + ("state", "H-22")) # H-21: an "opaque" op is a host's signal that it could not map this # payload to a known per-file shape at all (e.g. a FailClosedHost, or a @@ -207,6 +215,29 @@ def _run(root): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — the SAME generic branch + # pre-write.py carries (T-06/T-07 design ruling: one branch, not a + # per-tool special case). classify_protected already decided "state" is + # a hit; resolve_registered_path only resolves WHICH registered path and + # WHICH policy — never a second, independent membership check. + # marker-gated admits only under a fresh authoring marker; helper-only + # and append-only are flank-IDENTICAL here — both hard-block + # unconditionally, with NO marker path and no tail-anchored-append + # admission (unlike H-05's audit logs, an append-only registry entry's + # append verb lives in the sanctioned HELPER, not in this Edit flank). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — an " + f"Edit is admitted only under a fresh authoring marker. Run the " + f"sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Edit tools may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + sys.exit(0) diff --git a/plugins/ca-pi/hooks/pre-write.py b/plugins/ca-pi/hooks/pre-write.py index d0ec3088..b57c5747 100644 --- a/plugins/ca-pi/hooks/pre-write.py +++ b/plugins/ca-pi/hooks/pre-write.py @@ -11,6 +11,12 @@ # BLOCK into an allow (#160) — never writable via the Write tool. # H-05 audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) are append-only. # H-11 ADRs under decisions/ are authored only via /adr. +# H-22 the protected-state registry (B1/#564): a marker-gated entry admits +# only under a fresh authoring marker; a helper-only or append-only +# entry is hard-blocked with NO marker path at all — one generic +# branch resolves the entry's policy via _protectedstatelib and +# applies it, so a fourth registered file needs a registry entry, +# never a new hook branch. # # Every protected-path decision resolves symlinks (#162): classify_protected() # checks the raw path AND its realpath-resolved repo-relative form, so a symlink @@ -28,6 +34,9 @@ arbiter_active, block, classify_protected, frontmatter_enabled_text, get_host, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _guard_op(root, op): @@ -116,6 +125,29 @@ def _guard_op(root, op): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — ONE generic branch, + # regardless of `kind`. classify_protected already decided "state" is a + # hit; resolve_registered_path only resolves WHICH registered path + # matched and WHICH policy it carries — never a second, independent + # membership check (T-06 design ruling). marker-gated admits only under a + # fresh authoring marker; helper-only and append-only are + # flank-IDENTICAL here — both hard-block unconditionally, with NO marker + # path at all. The policies differ only in what the SANCTIONED HELPER's + # own file I/O is allowed to do, which this tool-call flank never sees by + # construction (its argv/content never reaches this guard). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — a " + f"Write is admitted only under a fresh authoring marker. Run " + f"the sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Write tool may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + def _run(root): # The host seam (ADR-0011, M2): iter_file_ops maps this host's native diff --git a/plugins/ca/hooks/_bashguardlib.py b/plugins/ca/hooks/_bashguardlib.py index 76907382..ed625c5c 100644 --- a/plugins/ca/hooks/_bashguardlib.py +++ b/plugins/ca/hooks/_bashguardlib.py @@ -57,6 +57,23 @@ # pre-bash.py — .github/scripts/ # test_hook_guards.py imports it by # this exact name) +# _state_write_res(basename) -> (redirect_re, write_re, git_restore_re, +# interp_re) H-22's per-entry +# shell-flank regex TEMPLATE for one +# protected-state registry entry's +# bare filename (T-08, #564; the +# git-restore and interpreter legs +# added per findings F5/F6) +# _build_state_write_res(registry) -> tuple[(rel_path, policy, redirect_re, +# write_re, git_restore_re, +# interp_re), ...] the compiled set +# for every entry in `registry`; +# `_STATE_WRITE_RES` is this, built +# once at import from the live +# `_protectedstatelib.REGISTRY` +# _check_h22_state(cmd, root) -> None H-22's run_guards() gate — block()s +# on a shell mutation of a registered +# protected-state file import os import re @@ -71,6 +88,12 @@ from _gitexec import git_executable import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE / # gitdir: pointer) project_root() climb (#223) +import _protectedstatelib # H-22's shell flank (T-08, #564) — imported as a + # module (not `from ... import REGISTRY`) so + # _STATE_WRITE_RES below is built from a live + # attribute lookup at import time, never a + # snapshotted name binding. +from _protectedstatelib import ProtectedPolicy, marker_gated_write_admitted # The most recent git-read failure, surfaced in the H-01/H-09b/H-14 fail-closed # block message. "git unavailable or timed out" alone cost a session of root- @@ -414,6 +437,149 @@ def _commit_no_verify_in_cluster(args): r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + GATE_MARKER, re.I, ) +# H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are +# guarded via classify_protected's "state" class (pre-write.py/pre-edit.py, +# T-05a/T-06/T-07); this closes the shell flank the SAME way the four +# pre-existing classes already do above: a redirect into a registered file, +# or a write/delete verb naming it, blocks. +# +# ONE (redirect_re, write_re) pair PER REGISTRY ENTRY, not a single +# alternation over every registered basename (T-08 design ruling): a regex +# defect in one entry's pattern then stays isolated to that entry — and is +# pinned by that entry's own test — instead of one opaque pattern smearing a +# failure across every consumer. `_state_write_res` is the per-entry +# TEMPLATE, called once per registry entry by `_build_state_write_res`. +# +# BARE basename anchor, deliberately WITHOUT a `.codearbiter/` directory +# prefix the way CONTEXT_MD carries one: the redirect-operator prefix +# (`>>?\|?\s*\S*`) and the verb-then-bounded-window shape +# (`\b(verb-list)\b[^|;&]*`) below are copied verbatim from +# CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE (above, ~line 355), but the NAME +# fragment itself mirrors AUDIT_LOG_NAMES/LOG_TRUNC_RE/LOG_DESTROY_RE's +# bare-filename anchor instead. This is not a stylistic choice: B-07/B-08's +# own non-regression cases, and the T-08b lexical residual, both name the +# protected file with NO `.codearbiter/` prefix at all — `taskwrite add -- +# "fix open-tasks.md schema"` and the false-blocking `taskwrite add -- "…tee +# open-tasks.md"` both carry the bare filename inside free-text argv. A +# directory-anchored pattern could neither reproduce the documented residual +# nor catch a real `tee open-tasks.md` run with a cwd already inside +# `.codearbiter/` (where no directory prefix appears in the command text +# either) — it would under-scan exactly the attack this guard exists for. +# The right-edge lookahead DECISION_LOG_SHELL_RE (#528, ~line 355) already +# carries. H-22's bare-basename anchor inherited the over-match closing it +# fixes without it: `rm .codearbiter/open-tasks.md.bak` matched, because the +# basename is a literal PREFIX-substring of the ".bak" spelling and nothing +# required the basename text to END where it should. Requires end-of-string, +# whitespace, a redirect/pipe/separator, or a quote-close immediately after +# the basename — never a bare `\b` word boundary alone, which cannot do this +# job here (a hyphen is a non-word character on BOTH its sides, so `\b` sits +# at a hyphen exactly as readily as at a `/`; it cannot distinguish +# "…/open-tasks.md" from "my-open-tasks.md"). +# +# This closes only the RIGHT-side over-match. The mirror-image LEFT-side one +# (a longer filename that happens to END with the registered basename, e.g. +# `my-open-tasks.md`, `> my-open-tasks.md`) is a KNOWN, ACCEPTED residual of +# the bare-basename anchor design itself (finding F4, #564 follow-up) — a +# left anchor would require knowing the basename is not itself part of a +# longer name, which the bare-anchor design (see the module comment above) +# deliberately does not have enough context to tell apart from a legitimate +# no-directory-prefix spelling. Declared, not merely implicit: see +# security-controls.md's "Protected-state registry (H-22)" section. +_STATE_NAME_RIGHT_EDGE = r"""(?=$|[\s>|;&"'])""" + +# The write-verb list, extended past the CONTEXT_WRITE_RE/DECISIONS_WRITE_RE +# baseline it was copied from (finding F6, #564 follow-up) with verbs +# present in this file's own cited precedents but missing here: `sponge` +# (already in LOG_DESTROY_RE, ~line 317), plus `ln` (a hardlinked/symlinked +# name overwrites whatever sits there with `ln -f`), `install` (coreutils' +# copy-with-permissions — a genuine overwrite verb), `patch` (rewrites a +# file in place from a diff), and `shred` (secure-delete, the ultimate +# destroy). `install`/`ln` both carry a real false-positive cost of their +# own (`npm install`/`pip install` are common phrases; `ln` is a short, +# common token) — accepted under the SAME "ambiguity resolves CLOSED" +# stance this file states at its own top (module docstring) and applies +# throughout (e.g. `cp overrides.log backup`, a mere READ, blocks anyway); +# declared in security-controls.md rather than left an undeclared gap. +_STATE_WRITE_VERBS = ( + r"rm|del|mv|cp|copy|dd|tee|sed|sponge|ln|install|patch|shred|truncate|ni" + r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content" + r"|Out-File|Add-Content" +) + + +def _state_write_res(basename): + r"""`(redirect_re, write_re, git_restore_re, interp_re)` for ONE + protected-state registry entry's bare filename — the compiled set + `_build_state_write_res` returns one of, per entry. See the module + comment above for why this is bare-basename, not directory-anchored, + and `_STATE_NAME_RIGHT_EDGE`/`_STATE_WRITE_VERBS` above for the + right-anchor and extended verb list (finding F4/F6). + + `git_restore_re` (finding F5, #564 follow-up): mirrors H-05's + LOG_GIT_RESTORE_RE (#335) — `git checkout`/`git restore` rewrite a + TRACKED worktree file through git itself, bypassing every filesystem + verb above entirely (all three planned registry entries are tracked + files, so this is not a hypothetical). A SEPARATE pattern, not folded + into the write-verb list: `checkout`/`restore` are git SUBCOMMANDS, not + shell verbs, and matching them needs the `GIT` global-options-tolerant + prefix the write-verb list has no business carrying. Deliberately does + NOT match `git add` — B-07's non-regression (commit-gate Phase 7 runs + `git add open-tasks.md` on every retained board flip, which must never + trip H-22) — and structurally cannot: the subcommand alternation here is + only `checkout|restore`. + + `interp_re` (finding F6, #564 follow-up): mirrors GATE_MARKER_INTERP_RE + (#237) — an arbitrary interpreter one-liner + (`python -c "open('open-tasks.md','w')..."`) reuses `helper-only`'s own + sanctioned Python file-I/O route while naming the target file lexically, + a flank no verb-list spelling above can see. `[\s\S]*` (not `[^\n]*`, + per the #237 follow-up) so the interpreter token and the filename may + sit on different physical lines of the SAME multi-line `-c`/`-e` + payload — `[^\n]*` cannot cross that newline and would silently reopen + the identical hole in its multi-line spelling.""" + name = re.escape(basename) + redirect_re = re.compile( + r">>?\|?\s*\S*" + name + _STATE_NAME_RIGHT_EDGE, re.I) + write_re = re.compile( + r"\b(" + _STATE_WRITE_VERBS + r")\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + git_restore_re = re.compile( + GIT + r"\s+(?:checkout|restore)\b[^|;&]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + interp_re = re.compile( + r"\b(python3?|node|perl|ruby|sh)\b[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I, + ) + return redirect_re, write_re, git_restore_re, interp_re + + +def _build_state_write_res(registry): + """`(rel_path, policy, redirect_re, write_re, git_restore_re, + interp_re)` for every entry in `registry`, keyed on each entry's bare + basename via `_state_write_res`. An explicit `registry` PARAMETER (not a + bare comprehension over the module-level default) so a test can rebuild + this exact tuple against a SYNTHETIC registry — the real one ships EMPTY + at this slice (T-01–T-08; T-33/T-65/T-66 enroll the three named + consumers later) — the same `registry=`-parameter shape + `_protectedstatelib.lookup_policy` already uses for the identical + reason.""" + built = [] + for rel_path, policy in registry.items(): + basename = rel_path.replace("\\", "/").rsplit("/", 1)[-1] + redirect_re, write_re, git_restore_re, interp_re = _state_write_res(basename) + built.append((rel_path, policy, redirect_re, write_re, git_restore_re, interp_re)) + return tuple(built) + + +# performance-002/_scopelib.py:109-117 precedent: compiled ONCE at import +# from the live (code-constant, never disk-loaded — #564 design ruling) +# registry, not recompiled per call. Empty at this slice, so +# `_check_h22_state` is correctly a no-op against every command until a +# consumer is registered. A test exercises the real logic by rebuilding this +# EXACT tuple against a synthetic registry (`_build_state_write_res`), never +# by mutating `_protectedstatelib.REGISTRY` after the fact — this tuple +# would not see that (it is a one-time import-time snapshot, by design). +_STATE_WRITE_RES = _build_state_write_res(_protectedstatelib.REGISTRY) + def git_cwd(cmd, root): """The directory a `git -C ` invocation actually targets, or `root` @@ -1042,6 +1208,50 @@ def _gate_marker_hit(view): "prohibited.") +def _check_h22_state(cmd, root): + """H-22: the protected-state registry's shell flank (B-04/B1, #564). + Walks the precompiled per-entry pairs (`_STATE_WRITE_RES`); a command + that redirects into, or runs a write/delete verb against, a registered + basename either admits (marker-gated, under a FRESH authoring marker — + `_protectedstatelib.marker_gated_write_admitted`, the H-11 pattern) or + blocks outright (helper-only/append-only — flank-IDENTICAL: the + distinction between them lives entirely in what the sanctioned helper's + OWN append verb is allowed to do, never in this shell guard, which has + no marker path for either). + + Marker checks read from the pinned `root` (project_root), never `cwd` — + the SAME split `_check_h09b_h10b_crypto_secret`/`_check_h14_migration` + already draw (D-2, `_effective_exec_root`'s own docstring): a linked + worktree has `.codearbiter/` (tracked) but not `.codearbiter/.markers/` + (gitignored), so marker paths must stay anchored at the main checkout. + + Git verbs are deliberately ABSENT from the write-verb list (the same + list `CONTEXT_WRITE_RE`/`DECISIONS_WRITE_RE` already use) — `git add + open-tasks.md` (commit-gate Phase 7, run on every retained board flip) + must never reach a block here (B-07), or commit-gate would block itself + on its own sanctioned board-flip staging. `git checkout`/`git restore` + ARE covered, but via the separate `git_restore_re` leg (finding F5, + #564 follow-up) — never the general write-verb list — precisely so that + isolation holds: `git_restore_re`'s subcommand alternation is only + `checkout|restore`, so it structurally cannot also catch `git add`.""" + for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in _STATE_WRITE_RES: + if not (redirect_re.search(cmd) or write_re.search(cmd) + or git_restore_re.search(cmd) or interp_re.search(cmd)): + continue + if policy == ProtectedPolicy.MARKER_GATED and marker_gated_write_admitted(rel_path, root): + continue + if policy == ProtectedPolicy.MARKER_GATED: + block("H-22", f"'{rel_path}' is marker-gated protected project state (#564) — a " + f"shell redirect or write/delete verb naming it is admitted only " + f"under a fresh authoring marker. Mint the marker via the sanctioned " + f"authoring lane, or /override.") + else: + block("H-22", f"'{rel_path}' is protected project state (#564, " + f"policy={policy.value}) — shell redirects and write/delete verbs " + f"naming it are prohibited outright; there is no marker path for " + f"this policy. Use the sanctioned helper.") + + def _check_h09b_h10b_crypto_secret(commit, add, cwd, root): """H-09b / H-10b: BLOCK a commit that introduces crypto/secret changes without a recorded security-gate pass. The crypto-compliance / secret-handling skills @@ -1229,6 +1439,7 @@ def run_guards(payload, root, ti): _check_h11_decisions(cmd) _check_h18_context_md(cmd) _check_h19_gate_marker(git_view, cmd, heredoc_shell_fallback) + _check_h22_state(cmd, root) _check_h09b_h10b_crypto_secret(commit, add, cwd, root) _check_h14_migration(commit, add, cwd, root) diff --git a/plugins/ca/hooks/_pathnorm.py b/plugins/ca/hooks/_pathnorm.py index c63dc6ae..a4ca0fcc 100644 --- a/plugins/ca/hooks/_pathnorm.py +++ b/plugins/ca/hooks/_pathnorm.py @@ -42,3 +42,33 @@ def repo_rel(fpath, root): rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root)) rel = rel.replace(os.sep, "/") return "" if rel == ".." or rel.startswith("../") else rel + + +def raw_repo_rel(fpath, root): + """`repo_rel`'s realpath-FREE twin: the repo-relative POSIX path for + `fpath` computed by pure lexical arithmetic against `root`, resolving + NEITHER side's symlinks — the raw spelling a host actually sent, or "" + when it cannot be expressed relative to `root` at all (outside root, or + on Windows a different drive). + + Exists for the classifiers that need BOTH the realpath-resolved form + AND the unresolved one to reproduce the #162 symlink-safety property + (`classify_protected` already tries a raw and a realpath'd form for + every legacy class, via regex `.search()` on the raw normalized string — + a shape that happens to also cover "the protected path itself is a + symlink" for those classes, because the pattern matches the raw text + regardless of what it resolves to). The equality-based `state` class + (`_protectedstatelib.lookup_policy`) has no equivalent for-free coverage + from a bare `norm_path(fpath)` — that string is very often absolute, + never equal to a repo-relative registry key — so a caller needing the + same raw-spelling coverage for an EQUALITY-based lookup should resolve + `fpath` against `root` here first, exactly like `repo_rel` does, just + without the `os.path.realpath()` call (T-06/F3, #564 follow-up).""" + if not fpath: + return "" + try: + rel = os.path.relpath(os.path.normpath(fpath), os.path.normpath(root)) + except ValueError: + return "" # Windows: fpath and root on different drives + rel = rel.replace(os.sep, "/") + return "" if rel == ".." or rel.startswith("../") else rel diff --git a/plugins/ca/hooks/_protectedlib.py b/plugins/ca/hooks/_protectedlib.py index d71d770a..e5ca02a2 100644 --- a/plugins/ca/hooks/_protectedlib.py +++ b/plugins/ca/hooks/_protectedlib.py @@ -20,12 +20,25 @@ # # _hooklib re-exports every name below, so no consumer changed and the # pre-existing hook suites prove parity without moving. +# +# T-05a (#564): a FIFTH class, "state", joins the four above — a path +# registered in the protected-state registry (_protectedstatelib.py, B1: a +# per-entry policy of marker-gated/helper-only/append-only). It is evaluated +# strictly AFTER the four legacy classes (a documented ordering, not just an +# implementation detail — see classify_protected's own docstring) and +# changes nothing about the RETURN CONTRACT: still a bare set of class-name +# strings, exactly as before. Resolving the registered path's POLICY is left +# entirely to whichever flank branch consumes the "state" tag +# (_protectedstatelib.lookup_policy / resolve_registered_path) — never done +# here, so this module gains no dependency on a POLICY concept it has no +# other reason to know about, and the four pre-existing consumers of +# classify_protected see zero contract change. from __future__ import annotations import re -from _pathnorm import norm_path, repo_rel +from _pathnorm import norm_path, raw_repo_rel, repo_rel # Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized @@ -110,6 +123,25 @@ # legitimately shell-written (the sanctioned producers are the python # security-pass.py / migration-pass.py helpers), unlike adr-authoring-active # which /adr legitimately `touch`es. +# +# T-08a / #564: this set enumerates markers whose PRESENCE converts a +# hard-gate BLOCK into an ALLOW (H-09b/H-10b's security-gate-passed, H-14's +# migration-gate-passed) — that conversion is the membership criterion, not +# "is this a marker under .markers/". A protected-state AUTHORING marker +# (e.g. release-targets-authoring, and the `-authoring` shape every +# future marker-gated consumer follows — _protectedstatelib.marker_name_for) +# does NOT belong here even though it also gates a write: it authorizes +# nothing on its own, the content it gates still passes through ordinary +# review, and it is deliberately self-mintable by an agent (a plain `touch`) +# under ADR-0010 — same as adr-authoring-active above. Its purpose is audit +# friction, not authorization. +# +# The risk here runs opposite to intuition: a future "hardening" pass that +# widens this blocklist to cover every registered marker would brick every +# legitimate authoring lane (a marker an agent can never touch admits +# nothing, ever) while stopping no non-cooperative agent (who can bypass +# this guard's own enforcement the same way they could bypass any other +# lexical shell check). See test_marker_touch_allowed (T-08a) for the pin. GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)" @@ -177,6 +209,44 @@ def is_marker_path(rel): return bool(MARKERS_RE.search(norm_path(rel))) +def is_registered_state_path(rel): + """True iff `rel` carries a policy in the protected-state registry + (`_protectedstatelib.lookup_policy`, B1/#564) — the fifth protected class + `classify_protected` recognizes (T-05a), evaluated strictly AFTER the + other four. + + The `_protectedstatelib` import is deliberately LAZY — done here, inside + the function body, rather than at this module's top level. + `_protectedstatelib` imports `marker_fresh` from `_hooklib`, and + `_hooklib` imports `classify_protected` (and every other name in this + file) FROM here — a module-level `import _protectedstatelib` at the top + of `_protectedlib.py` would be a genuine three-module import cycle + (`_protectedlib` -> `_protectedstatelib` -> `_hooklib` -> `_protectedlib`) + that Python cannot resolve at module-load time. Deferring the import to + CALL time breaks the cycle without restructuring the dependency graph: + every real caller reaches this function only after `_hooklib` (and + therefore this module) has already finished its own top-level execution, + so by the time the deferred import statement actually runs, + `_protectedstatelib`'s own `from _hooklib import marker_fresh` resolves + against an already-complete `_hooklib` module. + + Correction (finding F11, #564 follow-up): this is NOT the same pattern + `_babysitlib.babysit_config` or `statusline.py` use — neither of those + actually breaks an import CYCLE. `statusline.py`'s function-level + imports are a startup-latency lazy-load with no cyclic partner at all, + and `_babysitlib.py`'s deferred import sits inside a lazy default + injection where `_hooklib` never imports `_babysitlib` back (no cycle + to break). The deferral here IS still correct and necessary — it is + the only thing that breaks the genuine three-module cycle described + above — the prior comment simply cited the wrong precedent; corrected + so the comment matches the record. + + It is a local import, not file I/O or a git call, so it does not violate + this module's zero-side-effects-at-import invariant.""" + import _protectedstatelib + return _protectedstatelib.lookup_policy(rel) is not None + + def classify_protected(fpath, root): """The set of protected classes a Write/Edit `fpath` targets, resolving symlinks (#162). Each classifier runs against BOTH the raw normalized path @@ -184,11 +254,43 @@ def classify_protected(fpath, root): path lacks `.codearbiter/` still realpaths back inside the repo, so an alias can no longer launder a write past the guard. Centralized so pre-write.py and pre-edit.py apply the identical symlink-safe check to every class (H-05, - H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice. + H-11, #159 CONTEXT.md, #160 markers, #564 protected-state) instead of + re-encoding it twice. + + Classes: "audit", "decisions", "context", "marker", "state". repo_rel() + returns "" for a target outside the repo (which cannot be a + `.codearbiter` path), so that flank is simply skipped. - Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for - a target outside the repo (which cannot be a `.codearbiter` path), so that - flank is simply skipped.""" + "state" (T-05a, #564) is resolved in a SEPARATE loop below, strictly + AFTER the four legacy classes above — not interleaved into their loop. + That split is about incremental extension safety (a class added later + stays visibly separate from the four it was added beside, per the + module-header rationale above), NOT about the hits it produces: + `hits` is a `set`, so the SECOND loop's position relative to the first + has no observable effect on `classify_protected`'s own return value — + no test (mutant or otherwise) can distinguish "state resolved in loop 2" + from "state resolved inside loop 1" from the returned set alone + (finding F12, #564 follow-up correcting an earlier, overselling version + of this paragraph). The ordering that DOES matter or a real class/tag + conflict lives elsewhere and IS enforced in code: the textual branch + order of the `if "..." in classes:` checks in pre-write.py/pre-edit.py, + and pre-edit.py's own `_CLASS_TAG` priority tuple, which decide which + ONE message a caller emits when a path hits more than one class. + + The "state" loop tries BOTH a raw (symlink-unresolved) repo-relative + form (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`) — the SAME #162 two-form symlink-safety property the four + legacy classes get for free from `.search()`-based regex matching over + the raw path text, restored here explicitly (finding F3) because an + EQUALITY-based lookup (`_protectedstatelib.lookup_policy`) does not get + that coverage for free from a bare `norm_path(fpath)` — see + `_protectedstatelib.resolve_registered_path`'s docstring for the full + account of the symlink-inversion bug this closes. The return contract is + unchanged by this addition: still a bare set of strings. A caller that + needs the registered path's POLICY (marker-gated/helper-only/ + append-only) resolves it separately, via `_protectedstatelib` — this + function only ever reports CLASS membership, exactly as it always has, + so its four pre-existing consumers see no contract change.""" hits = set() for p in (norm_path(fpath), repo_rel(fpath, root)): if not p: @@ -201,4 +303,10 @@ def classify_protected(fpath, root): hits.add("context") if is_marker_path(p): hits.add("marker") + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + if is_registered_state_path(p): + hits.add("state") + break return hits diff --git a/plugins/ca/hooks/_protectedstatelib.py b/plugins/ca/hooks/_protectedstatelib.py index 82c87561..b2f93e7e 100644 --- a/plugins/ca/hooks/_protectedstatelib.py +++ b/plugins/ca/hooks/_protectedstatelib.py @@ -60,6 +60,21 @@ # dict, which would drop the # raw-and-realpath symlink coverage that # dispatch provides. +# resolve_registered_path(fpath, root, registry=None) +# -> (rel_path, ProtectedPolicy) | (None, +# None). The T-06/T-07 flank helper: once +# classify_protected has already reported +# "state" for `fpath`, this resolves +# WHICH registered path matched and WHICH +# policy it carries, trying both the raw +# normalized path and its +# realpath-resolved repo-relative form - +# the SAME two forms classify_protected +# itself tries (#162) - so the flank +# resolves the identical entry +# classify_protected saw rather than +# re-deriving membership through an +# independent check. # MARKER_FRESHNESS_MINUTES -> int the H-11 marker window (30). Matches # the ADR-authoring gate's value by # convention, not a shared import - see @@ -106,7 +121,7 @@ from enum import Enum from _hooklib import marker_fresh -from _pathnorm import norm_path +from _pathnorm import norm_path, raw_repo_rel, repo_rel class ProtectedPolicy(str, Enum): @@ -141,17 +156,70 @@ class ProtectedPolicy(str, Enum): REGISTRY: dict[str, ProtectedPolicy] = {} +def _canon(rel_path): + """Canonical comparison form of a repo-relative path: separator- + normalized (`norm_path`), then whitespace-stripped, `./`-prefix- + stripped (repeatable — "././x" too), doubled-slash-collapsed, + trailing-slash-stripped, and finally case-folded. + + Applied to BOTH sides of every `lookup_policy` comparison (the query + path AND every registry key) so a spelling difference on either side + degrades to "still matches" rather than "silently matches nothing" — + the same "a malformed key degrades the way a malformed query path does" + principle `lookup_policy`'s own docstring states, extended to cover the + specific spellings its docstring already promised but the OLD + norm_path-only comparison silently missed (#564 follow-up, finding F2): + a leading `./`, a trailing slash, a doubled slash, and a leading space. + + Case-folded — not merely separator-normalized — for a second, + independent reason (finding F1): `_bashguardlib.py`'s H-22 shell-flank + regexes compile with `re.I` (`_state_write_res`), so a case-sensitive + equality check here would let the two flanks disagree on whether a + differently-cased spelling of a registered path is protected. On a + case-preserving-but-insensitive filesystem (default macOS/APFS, + Windows/NTFS) that disagreement is a live fail-open: `_protectedlib. + classify_protected` resolves through `os.path.realpath`, which does + NOT canonicalize case for an EXISTING path on a case-insensitive mount + (posixpath.realpath never folds case at all; even `nt.realpath`, which + does resolve an existing file's on-disk case, cannot help a NOT-YET- + created file — exactly the Write that creates a protected-state file + for the first time) — so `Write(".codearbiter/Open-Tasks.md")` could + reach this equality check with a case that never gets folded away + before comparison. This module deliberately picks ONE fixed rule, + case-INSENSITIVE, GLOBALLY, rather than "whatever this host's + filesystem happens to do": matching host behavior is not obviously + right either (it varies per platform AND per volume on the same + platform), and a fixed global rule is the only option `_bashguardlib.py` + can mirror without itself inspecting the filesystem. Choosing + case-INSENSITIVE (not case-sensitive) only WIDENS what H-22 protects — + consistent with this codebase's "ambiguity resolves CLOSED" stance + (module comment, `_bashguardlib.py`) — at the cost of a same-directory + file whose name differs from a registered path ONLY by case (e.g. a + genuinely different `OPEN-TASKS.MD`) being treated as protected too; a + registry entry choosing a name that collides with a real sibling file + under a case change is expected to be rare enough that this is judged + the right trade.""" + p = norm_path(rel_path).strip() + while p.startswith("./"): + p = p[2:] + while "//" in p: + p = p.replace("//", "/") + p = p.rstrip("/") + return p.lower() + + def lookup_policy(rel_path, registry=None): """The ProtectedPolicy registered for `rel_path`, or None if it carries - no policy. Both `rel_path` and every registry key are separator- - normalized before comparison, so a Windows backslash path matches a - registry keyed with forward slashes (the same normalization every other - _*lib.py classifier applies via norm_path) AND a registry entry that was - itself typo'd with a backslash, a `./` prefix character, or a trailing - slash still matches rather than silently protecting nothing - a - malformed *key* degrades the same way a malformed query path does; only a - malformed *policy* (see ProtectedPolicy) is an internal error worth - raising on. + no policy. Both `rel_path` and every registry key are canonicalized + (`_canon`, above) before comparison — separator-normalized, `./`/ + doubled-slash/trailing-slash/leading-space tolerant, and + case-INSENSITIVE (deliberately, globally — see `_canon`'s docstring for + why) — so a Windows backslash path, a `./`-prefixed or trailing-slash + query, or a differently-cased spelling all match a registry entry, AND + a registry entry that was itself typo'd any of those ways still matches + rather than silently protecting nothing - a malformed *key* degrades the + same way a malformed query path does; only a malformed *policy* (see + ProtectedPolicy) is an internal error worth raising on. `registry` defaults to the module-level REGISTRY; a test (or a future caller) may pass a synthetic dict instead, which is what keeps this @@ -163,13 +231,67 @@ def lookup_policy(rel_path, registry=None): alias this module does not itself guard against.""" if registry is None: registry = REGISTRY - normalized = norm_path(rel_path) + normalized = _canon(rel_path) for key, policy in registry.items(): - if norm_path(key) == normalized: + if _canon(key) == normalized: return policy return None +def resolve_registered_path(fpath, root, registry=None): + """The `(rel_path, policy)` pair a Write/Edit/shell target resolves to, + once `_protectedlib.classify_protected` has already reported "state" for + it - or `(None, None)` if it turns out to carry no registry entry after + all (a caller that checks this before ever consulting + classify_protected, or a stale class set). + + Tries BOTH the raw (symlink-unresolved) repo-relative form + (`raw_repo_rel`) and the realpath-resolved repo-relative form + (`repo_rel`), in that order - the SAME two-form symlink-safety property + (#162) classify_protected's four legacy classes get automatically from + running a regex `.search()` over the raw normalized path text. + + That automatic coverage does NOT transfer for free to this module's + EQUALITY-based lookup (finding F3, #564 follow-up): `norm_path(fpath)` - + almost always an ABSOLUTE path, since every host sends one - is never + equal to a repo-relative registry key, so trying it as the "raw" leg was + inert (it could never match anything). Worse, it made symlink coverage + the WRONG WAY ROUND versus the other four classes: when the REGISTERED + PATH ITSELF is a symlink pointing somewhere unregistered, + `os.path.realpath` resolves the ONLY spelling a host actually sends + (the absolute path) straight through the symlink to that unregistered + target, and the dead raw leg supplied no alternative route back to the + registered name — so the write was silently ADMITTED, the opposite of + the legacy classes' behavior in the equivalent scenario (a regex + `.search()` still matches the raw path text regardless of where it + realpaths to). `raw_repo_rel` fixes this: computed by pure lexical + arithmetic against `root` (no `os.path.realpath` call), it still names + the registered entry syntactically even when the path is a symlink, so + that spelling now resolves correctly too - restoring the SAME + "protected either way you spell it" guarantee the legacy classes + already had; the realpath leg still exists for the mirror-image case + (a symlinked DIRECTORY whose visible path lacks the registered prefix + but resolves into it). + + So a flank reaching this function resolves the IDENTICAL entry + classify_protected saw, rather than re-deriving membership through an + independent check. That independent-check shape is exactly what #564's + design forbids ("no second, parallel lookup") - this function only ever + RESOLVES what classify_protected already decided; it never decides + membership on its own account. + + `registry` defaults to the module-level REGISTRY, matching + `lookup_policy`'s own parameter shape, for the same reason: a test (or a + future caller) may pass a synthetic dict.""" + for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)): + if not p: + continue + policy = lookup_policy(p, registry) + if policy is not None: + return p, policy + return None, None + + # The H-11 authoring-marker freshness window, matching the existing # ADR-authoring gate's value by convention, NOT by a shared import: # pre-write.py, pre-edit.py, _bashguardlib.py, and git-enforce.py each diff --git a/plugins/ca/hooks/pre-edit.py b/plugins/ca/hooks/pre-edit.py index e08a2ca1..24eb2067 100755 --- a/plugins/ca/hooks/pre-edit.py +++ b/plugins/ca/hooks/pre-edit.py @@ -11,6 +11,10 @@ # be the file's REAL current trailing content, new_string must extend # it, and replace_all is rejected outright (reliability-003, #172). # H-11 ADRs under decisions/ are edited only via /adr. +# H-22 the protected-state registry (B1/#564): the SAME generic branch as +# pre-write.py — marker-gated admits only under a fresh authoring +# marker; helper-only and append-only are hard-blocked, no marker +# path at all. # # NotebookEdit is guarded too (a notebook has no append/frontmatter semantics, so # a protected target is refused outright) — defense in depth; none of the @@ -38,6 +42,9 @@ get_host, is_tail_append, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _read_real_text(root, fpath): @@ -93,7 +100,8 @@ def _run(root): # The tag that names each protected class, in message priority order. _CLASS_TAG = (("marker", "H-19"), ("context", "H-18"), - ("audit", "H-05"), ("decisions", "H-11")) + ("audit", "H-05"), ("decisions", "H-11"), + ("state", "H-22")) # H-21: an "opaque" op is a host's signal that it could not map this # payload to a known per-file shape at all (e.g. a FailClosedHost, or a @@ -207,6 +215,29 @@ def _run(root): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — the SAME generic branch + # pre-write.py carries (T-06/T-07 design ruling: one branch, not a + # per-tool special case). classify_protected already decided "state" is + # a hit; resolve_registered_path only resolves WHICH registered path and + # WHICH policy — never a second, independent membership check. + # marker-gated admits only under a fresh authoring marker; helper-only + # and append-only are flank-IDENTICAL here — both hard-block + # unconditionally, with NO marker path and no tail-anchored-append + # admission (unlike H-05's audit logs, an append-only registry entry's + # append verb lives in the sanctioned HELPER, not in this Edit flank). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — an " + f"Edit is admitted only under a fresh authoring marker. Run the " + f"sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Edit tools may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + sys.exit(0) diff --git a/plugins/ca/hooks/pre-write.py b/plugins/ca/hooks/pre-write.py index d0ec3088..b57c5747 100755 --- a/plugins/ca/hooks/pre-write.py +++ b/plugins/ca/hooks/pre-write.py @@ -11,6 +11,12 @@ # BLOCK into an allow (#160) — never writable via the Write tool. # H-05 audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) are append-only. # H-11 ADRs under decisions/ are authored only via /adr. +# H-22 the protected-state registry (B1/#564): a marker-gated entry admits +# only under a fresh authoring marker; a helper-only or append-only +# entry is hard-blocked with NO marker path at all — one generic +# branch resolves the entry's policy via _protectedstatelib and +# applies it, so a fourth registered file needs a registry entry, +# never a new hook branch. # # Every protected-path decision resolves symlinks (#162): classify_protected() # checks the raw path AND its realpath-resolved repo-relative form, so a symlink @@ -28,6 +34,9 @@ arbiter_active, block, classify_protected, frontmatter_enabled_text, get_host, marker_fresh, project_root, read_input, set_host, utf8_stdio, ) +from _protectedstatelib import ( # noqa: E402 + ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path, +) def _guard_op(root, op): @@ -116,6 +125,29 @@ def _guard_op(root, op): if not marker_fresh(marker, 30): block("H-11", "ADR authoring marker is stale (>30 min). Re-run /adr.") + # H-22: the protected-state registry (B1/#564) — ONE generic branch, + # regardless of `kind`. classify_protected already decided "state" is a + # hit; resolve_registered_path only resolves WHICH registered path + # matched and WHICH policy it carries — never a second, independent + # membership check (T-06 design ruling). marker-gated admits only under a + # fresh authoring marker; helper-only and append-only are + # flank-IDENTICAL here — both hard-block unconditionally, with NO marker + # path at all. The policies differ only in what the SANCTIONED HELPER's + # own file I/O is allowed to do, which this tool-call flank never sees by + # construction (its argv/content never reaches this guard). + if "state" in classes: + rel, policy = resolve_registered_path(fpath, root) + if policy == ProtectedPolicy.MARKER_GATED: + if not marker_gated_write_admitted(rel, root): + block("H-22", f"'{rel}' is marker-gated protected project state (#564) — a " + f"Write is admitted only under a fresh authoring marker. Run " + f"the sanctioned authoring lane, or " + + get_host().cmd_ref("override") + ".") + else: + block("H-22", f"'{rel}' is protected project state (#564) — the Write tool may " + f"never target it; there is no marker path for this policy. Use " + f"the sanctioned helper.") + def _run(root): # The host seam (ADR-0011, M2): iter_file_ops maps this host's native diff --git a/plugins/ca/hooks/tests/test_pre_edit.py b/plugins/ca/hooks/tests/test_pre_edit.py index 8324a531..2d3850f4 100644 --- a/plugins/ca/hooks/tests/test_pre_edit.py +++ b/plugins/ca/hooks/tests/test_pre_edit.py @@ -21,6 +21,8 @@ Stdlib only (project policy: hooks and their tests carry no dependencies). """ +import importlib.util as _ilu +import io import json import os import subprocess @@ -31,6 +33,7 @@ HOOKS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PRE_EDIT = os.path.join(HOOKS, "pre-edit.py") +sys.path.insert(0, HOOKS) def _sh(args, cwd, **kw): @@ -458,6 +461,160 @@ def test_notebookedit_on_real_notebook_is_allowed(self): self.assertAllowed(self.run_notebook(nb)) +def _load_pre_edit(): + """Load pre-edit.py as an IN-PROCESS module (mirrors + test_guard_crash_failclosed.py's `_load` pattern), for the SAME reason + test_pre_write.py's `_load_pre_write` does: T-07's H-22 branch needs a + SYNTHETIC protected-state registry, and `_protectedstatelib.REGISTRY` is + read fresh on every `lookup_policy`/`resolve_registered_path` call — a + mutation is only visible to a call made from THIS SAME process, never + to a subprocess's own, separately-loaded, empty REGISTRY.""" + spec = _ilu.spec_from_file_location("pre_edit_h22test", PRE_EDIT) + mod = _ilu.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestH22ProtectedState(unittest.TestCase): + """T-07 (#564): the SAME generic "state" branch pre-write.py carries. + marker-gated admits only under a fresh authoring marker; helper-only/ + append-only hard-block unconditionally, with NO marker path — and no + tail-anchored-append admission (unlike H-05's audit logs).""" + + ARBITER = "---\narbiter: enabled\nstage: 2\n---\n\nfixture\n" + + def setUp(self): + import _protectedstatelib + self.mod = _load_pre_edit() + self._protectedstatelib = _protectedstatelib + self._orig_registry = _protectedstatelib.REGISTRY + self._tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self._tmp.name, "repo") + self.ca = os.path.join(self.root, ".codearbiter") + self.markers = os.path.join(self.ca, ".markers") + os.makedirs(self.ca) + with open(os.path.join(self.ca, "CONTEXT.md"), "w", encoding="utf-8") as f: + f.write(self.ARBITER) + with open(os.path.join(self.ca, "release-targets.md"), "w", encoding="utf-8") as f: + f.write("| target | prefix |\n|---|---|\n| ca | v |\n") + with open(os.path.join(self.ca, "open-tasks.md"), "w", encoding="utf-8") as f: + f.write("# Open tasks\n\n- [ ] seed\n") + self._orig_env = os.environ.get("CLAUDE_PROJECT_DIR") + os.environ["CLAUDE_PROJECT_DIR"] = self.root + + def tearDown(self): + self._protectedstatelib.REGISTRY = self._orig_registry + if self._orig_env is None: + os.environ.pop("CLAUDE_PROJECT_DIR", None) + else: + os.environ["CLAUDE_PROJECT_DIR"] = self._orig_env + self._tmp.cleanup() + + def _set_registry(self, registry): + self._protectedstatelib.REGISTRY = registry + + def _touch_marker(self, name, age_seconds=0): + os.makedirs(self.markers, exist_ok=True) + m = os.path.join(self.markers, name) + with open(m, "w", encoding="utf-8") as f: + f.write("active\n") + if age_seconds: + past = time.time() - age_seconds + os.utime(m, (past, past)) + return m + + def _run(self, payload): + old_stdin, old_stdout, old_stderr = sys.stdin, sys.stdout, sys.stderr + sys.stdin = io.StringIO(json.dumps(payload)) + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + try: + with self.assertRaises(SystemExit) as ctx: + self.mod.main() + return ctx.exception.code, sys.stderr.getvalue() + finally: + sys.stdin, sys.stdout, sys.stderr = old_stdin, old_stdout, old_stderr + + def run_edit(self, file_path, old_string, new_string): + return self._run({"tool_name": "Edit", + "tool_input": {"file_path": file_path, "old_string": old_string, + "new_string": new_string}}) + + def assertBlockedH22(self, res): + code, err = res + self.assertEqual(code, 2, err) + self.assertIn("H-22", err, err) + + def assertAllowed(self, res): + code, err = res + self.assertEqual(code, 0, err) + + def test_marker_gated_edit_without_marker_is_blocked(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self.assertBlockedH22(self.run_edit( + os.path.join(self.ca, "release-targets.md"), old_string="v", new_string="w")) + + def test_marker_gated_edit_with_fresh_marker_is_allowed(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self._touch_marker("release-targets-authoring") + self.assertAllowed(self.run_edit( + os.path.join(self.ca, "release-targets.md"), old_string="v", new_string="w")) + + def test_marker_gated_edit_with_stale_marker_is_blocked(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self._touch_marker("release-targets-authoring", age_seconds=31 * 60) + self.assertBlockedH22(self.run_edit( + os.path.join(self.ca, "release-targets.md"), old_string="v", new_string="w")) + + def test_helper_only_edit_is_blocked_unconditionally(self): + self._set_registry({".codearbiter/open-tasks.md": + self._protectedstatelib.ProtectedPolicy.HELPER_ONLY}) + self.assertBlockedH22(self.run_edit( + os.path.join(self.ca, "open-tasks.md"), old_string="seed", new_string="seed2")) + + def test_helper_only_tail_anchored_append_is_STILL_blocked(self): + # Unlike H-05's audit logs, a helper-only registry entry gets NO + # append admission at this flank — the append verb lives entirely + # inside the sanctioned helper, never in this Edit guard. + self._set_registry({".codearbiter/open-tasks.md": + self._protectedstatelib.ProtectedPolicy.HELPER_ONLY}) + current = "# Open tasks\n\n- [ ] seed\n" + self.assertBlockedH22(self.run_edit( + os.path.join(self.ca, "open-tasks.md"), old_string=current, + new_string=current + "- [ ] more\n")) + + def test_append_only_edit_is_blocked_unconditionally(self): + self._set_registry({".codearbiter/done-tasks.md": + self._protectedstatelib.ProtectedPolicy.APPEND_ONLY}) + with open(os.path.join(self.ca, "done-tasks.md"), "w", encoding="utf-8") as f: + f.write("# Done tasks\n") + self.assertBlockedH22(self.run_edit( + os.path.join(self.ca, "done-tasks.md"), + old_string="# Done tasks\n", new_string="# Done tasks\n\n- x (done 2026-01-01)\n")) + + def test_notebookedit_targeting_state_path_is_blocked(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + res = self._run({"tool_name": "NotebookEdit", + "tool_input": {"notebook_path": os.path.join(self.ca, "release-targets.md"), + "new_source": "print(1)"}}) + self.assertBlockedH22(res) + + def test_unregistered_path_is_unaffected(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self.assertAllowed(self.run_edit( + os.path.join(self.ca, "open-tasks.md"), old_string="seed", new_string="seed2")) + + def test_default_empty_registry_blocks_nothing_new(self): + # The REAL production registry — empty at this slice. + self.assertAllowed(self.run_edit( + os.path.join(self.ca, "release-targets.md"), old_string="v", new_string="w")) + + class TestPreEditAllowPaths(_PreEditFixture): """Cases where neither guard should fire.""" diff --git a/plugins/ca/hooks/tests/test_pre_write.py b/plugins/ca/hooks/tests/test_pre_write.py index 870719a6..b49c5872 100644 --- a/plugins/ca/hooks/tests/test_pre_write.py +++ b/plugins/ca/hooks/tests/test_pre_write.py @@ -13,6 +13,8 @@ Same subprocess style as test_pre_edit.py: Claude-Code-shaped hook JSON piped to pre-write.py on stdin, cwd'd into a throwaway arbiter-enabled repo. Stdlib only. """ +import importlib.util as _ilu +import io import json import os import subprocess @@ -23,6 +25,7 @@ HOOKS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PRE_WRITE = os.path.join(HOOKS, "pre-write.py") +sys.path.insert(0, HOOKS) def _sh(args, cwd, **kw): @@ -208,6 +211,166 @@ def test_symlinked_marker_dir_blocks_write(self): content="d\n"), "H-19") +def _load_pre_write(): + """Load pre-write.py as an IN-PROCESS module (mirrors + test_guard_crash_failclosed.py's `_load` pattern) rather than a + subprocess. T-06's H-22 branch needs a SYNTHETIC protected-state + registry — REGISTRY ships empty at this slice (T-33/T-65/T-66 enroll + consumers later) — and `_protectedstatelib.lookup_policy`/ + `resolve_registered_path` read the module-level REGISTRY fresh on every + call (unlike the shell flank's precompiled `_STATE_WRITE_RES`), so + mutating `_protectedstatelib.REGISTRY` directly is visible to a call + made from THIS SAME process — a call inside a pre-write.py SUBPROCESS + would just re-import its own, separately-loaded, empty REGISTRY.""" + spec = _ilu.spec_from_file_location("pre_write_h22test", PRE_WRITE) + mod = _ilu.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestH22ProtectedState(unittest.TestCase): + """T-06 (#564): the generic "state" branch. marker-gated admits only + under a fresh authoring marker; helper-only/append-only hard-block + unconditionally, with NO marker path at all.""" + + ARBITER = "---\narbiter: enabled\nstage: 2\n---\n\nfixture\n" + + def setUp(self): + import _protectedstatelib + self.mod = _load_pre_write() + self._protectedstatelib = _protectedstatelib + self._orig_registry = _protectedstatelib.REGISTRY + self._tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self._tmp.name, "repo") + self.ca = os.path.join(self.root, ".codearbiter") + self.markers = os.path.join(self.ca, ".markers") + os.makedirs(self.ca) + with open(os.path.join(self.ca, "CONTEXT.md"), "w", encoding="utf-8") as f: + f.write(self.ARBITER) + self._orig_env = os.environ.get("CLAUDE_PROJECT_DIR") + os.environ["CLAUDE_PROJECT_DIR"] = self.root + + def tearDown(self): + self._protectedstatelib.REGISTRY = self._orig_registry + if self._orig_env is None: + os.environ.pop("CLAUDE_PROJECT_DIR", None) + else: + os.environ["CLAUDE_PROJECT_DIR"] = self._orig_env + self._tmp.cleanup() + + def _set_registry(self, registry): + self._protectedstatelib.REGISTRY = registry + + def _touch_marker(self, name, age_seconds=0): + os.makedirs(self.markers, exist_ok=True) + m = os.path.join(self.markers, name) + with open(m, "w", encoding="utf-8") as f: + f.write("active\n") + if age_seconds: + past = time.time() - age_seconds + os.utime(m, (past, past)) + return m + + def run_write(self, file_path, content="x\n"): + payload = {"tool_name": "Write", "tool_input": {"file_path": file_path, "content": content}} + old_stdin, old_stdout, old_stderr = sys.stdin, sys.stdout, sys.stderr + sys.stdin = io.StringIO(json.dumps(payload)) + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + try: + with self.assertRaises(SystemExit) as ctx: + self.mod.main() + return ctx.exception.code, sys.stderr.getvalue() + finally: + sys.stdin, sys.stdout, sys.stderr = old_stdin, old_stdout, old_stderr + + def assertBlockedH22(self, res): + code, err = res + self.assertEqual(code, 2, err) + self.assertIn("H-22", err, err) + + def assertAllowed(self, res): + code, err = res + self.assertEqual(code, 0, err) + + def test_marker_gated_write_without_marker_is_blocked(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self.assertBlockedH22(self.run_write( + os.path.join(self.ca, "release-targets.md"))) + + def test_marker_gated_write_with_fresh_marker_is_allowed(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self._touch_marker("release-targets-authoring") + self.assertAllowed(self.run_write( + os.path.join(self.ca, "release-targets.md"))) + + def test_marker_gated_write_with_stale_marker_is_blocked(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self._touch_marker("release-targets-authoring", age_seconds=31 * 60) + self.assertBlockedH22(self.run_write( + os.path.join(self.ca, "release-targets.md"))) + + def test_helper_only_write_is_blocked_unconditionally(self): + self._set_registry({".codearbiter/open-tasks.md": + self._protectedstatelib.ProtectedPolicy.HELPER_ONLY}) + self.assertBlockedH22(self.run_write(os.path.join(self.ca, "open-tasks.md"))) + + def test_helper_only_write_is_blocked_even_with_a_marker_present(self): + # helper-only has NO marker path at all — a marker present under + # ANY name must not admit it. + self._set_registry({".codearbiter/open-tasks.md": + self._protectedstatelib.ProtectedPolicy.HELPER_ONLY}) + self._touch_marker("open-tasks-authoring") + self.assertBlockedH22(self.run_write(os.path.join(self.ca, "open-tasks.md"))) + + def test_append_only_write_is_blocked_unconditionally(self): + self._set_registry({".codearbiter/done-tasks.md": + self._protectedstatelib.ProtectedPolicy.APPEND_ONLY}) + self.assertBlockedH22(self.run_write(os.path.join(self.ca, "done-tasks.md"))) + + def test_unregistered_path_is_unaffected(self): + self._set_registry({".codearbiter/release-targets.md": + self._protectedstatelib.ProtectedPolicy.MARKER_GATED}) + self.assertAllowed(self.run_write(os.path.join(self.ca, "open-tasks.md"))) + + def test_default_empty_registry_blocks_nothing_new(self): + # The REAL production registry — empty at this slice. + self.assertAllowed(self.run_write(os.path.join(self.ca, "release-targets.md"))) + + @unittest.skipUnless(_symlinks_supported(), "symlink creation not permitted here") + def test_symlinked_dir_alias_blocks_registered_state_file(self): + # F10 (#564 follow-up): the #162 symlink property TestSymlinkAlias + # (above) pins for the four legacy classes had no "state" case. A + # symlinked DIRECTORY whose visible path lacks .codearbiter/ but + # resolves into it must still hit "state" via the REALPATH leg. + self._set_registry({".codearbiter/open-tasks.md": + self._protectedstatelib.ProtectedPolicy.HELPER_ONLY}) + alias = os.path.join(self.root, "alias") + os.symlink(self.ca, alias, target_is_directory=True) + self.assertBlockedH22(self.run_write(os.path.join(alias, "open-tasks.md"))) + + @unittest.skipUnless(_symlinks_supported(), "symlink creation not permitted here") + def test_symlinked_protected_file_still_blocks_via_raw_spelling(self): + # F10/F3 (#564 follow-up): the MIRROR-IMAGE case, and the one the + # OLD "raw leg" (norm_path(fpath), inert for an equality-based + # lookup) got backwards — when the PROTECTED PATH ITSELF is a + # symlink, realpath resolves the only spelling a host actually + # sends (the absolute path) straight through it to an unregistered + # target. The raw (symlink-unresolved) repo-relative form is what + # still recognizes the registered NAME. + self._set_registry({".codearbiter/open-tasks.md": + self._protectedstatelib.ProtectedPolicy.HELPER_ONLY}) + decoy = os.path.join(self.root, "decoy.md") + with open(decoy, "w", encoding="utf-8") as f: + f.write("not protected\n") + link = os.path.join(self.ca, "open-tasks.md") + os.symlink(decoy, link) + self.assertBlockedH22(self.run_write(link)) + + class TestPreWriteAllowPaths(_PreWriteFixture): def test_disabled_arbiter_is_noop(self): self._disable_arbiter() diff --git a/plugins/ca/hooks/tests/test_protectedstatelib.py b/plugins/ca/hooks/tests/test_protectedstatelib.py index 8fefb589..acfc5aee 100644 --- a/plugins/ca/hooks/tests/test_protectedstatelib.py +++ b/plugins/ca/hooks/tests/test_protectedstatelib.py @@ -1,10 +1,14 @@ +import io import os +import subprocess import sys import tempfile import time import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import _bashguardlib # noqa: E402 +import _protectedlib # noqa: E402 import _protectedstatelib # noqa: E402 from _protectedstatelib import ( # noqa: E402 MARKER_FRESHNESS_MINUTES, @@ -12,8 +16,24 @@ lookup_policy, marker_gated_write_admitted, marker_name_for, + resolve_registered_path, ) +HOOKS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PRE_BASH = os.path.join(HOOKS, "pre-bash.py") + + +def _symlinks_supported(): + """Windows CI runners often lack the privilege to create symlinks; skip + the symlink cases there (mirrors test_pre_write.py's identically-named + helper).""" + try: + with tempfile.TemporaryDirectory() as d: + os.symlink(os.path.join(d, "t"), os.path.join(d, "l")) + return True + except (OSError, NotImplementedError, AttributeError): + return False + def _touch(path, age_seconds=0): """Create an empty file at `path`, optionally backdating its mtime by @@ -88,6 +108,70 @@ def test_registry_lookup_normalizes_backslash_registry_keys(self): ProtectedPolicy.MARKER_GATED, ) + def test_registry_lookup_is_case_insensitive(self): + # F1 (#564 follow-up): the shell flank's H-22 regexes compile with + # re.I (_bashguardlib._state_write_res) - a case-SENSITIVE + # lookup_policy would let the two flanks disagree on a + # differently-cased spelling. Deliberately global, not host-fs- + # dependent (see _canon's docstring) - a query that differs from the + # registered key ONLY by case still resolves. + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertEqual( + lookup_policy(".codearbiter/Open-Tasks.md", registry), + ProtectedPolicy.HELPER_ONLY, + ) + self.assertEqual( + lookup_policy(".codearbiter/OPEN-TASKS.MD", registry), + ProtectedPolicy.HELPER_ONLY, + ) + + def test_registry_lookup_is_case_insensitive_on_the_key_too(self): + # The symmetric direction: a registry KEY typo'd in the wrong case + # must not silently protect nothing either (mirrors the backslash + # key-normalization test above, for case). + registry = {".codearbiter/Open-Tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertEqual( + lookup_policy(".codearbiter/open-tasks.md", registry), + ProtectedPolicy.HELPER_ONLY, + ) + + def test_registry_lookup_tolerates_leading_dot_slash(self): + # F2 (#564 follow-up): the docstring at lines 161-169 promises this + # spelling matches; norm_path() alone (separator swap only) does not + # strip it. + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertEqual( + lookup_policy("./.codearbiter/open-tasks.md", registry), + ProtectedPolicy.HELPER_ONLY, + ) + + def test_registry_lookup_tolerates_a_trailing_slash(self): + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertEqual( + lookup_policy(".codearbiter/open-tasks.md/", registry), + ProtectedPolicy.HELPER_ONLY, + ) + + def test_registry_lookup_tolerates_a_doubled_slash(self): + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertEqual( + lookup_policy(".codearbiter//open-tasks.md", registry), + ProtectedPolicy.HELPER_ONLY, + ) + + def test_registry_lookup_tolerates_a_leading_space(self): + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertEqual( + lookup_policy(" .codearbiter/open-tasks.md", registry), + ProtectedPolicy.HELPER_ONLY, + ) + + def test_registry_lookup_still_rejects_a_genuinely_different_path(self): + # None of the tolerance above should turn lookup_policy into a + # substring match - a real, different path must still miss. + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + self.assertIsNone(lookup_policy(".codearbiter/other-tasks.md", registry)) + def test_registry_lookup_default_registry_has_no_hardcoded_consumers(self): # B1 ships the registry mechanism, not entries - release-targets.md # is registered by its OWN later task (B-13), never by this module. @@ -262,5 +346,610 @@ def test_marker_name_for_degenerate_input_never_collides_with_a_real_path(self): self.assertNotEqual(marker_name_for(""), marker_name_for("release-targets.md")) +class TestNoLegacyOverlap(unittest.TestCase): + """B-01/T-05b: a registered protected-state path must not ALSO classify + into any of the four legacy classes (audit/decisions/context/marker). + Overlap between an independently-checked registry entry and a legacy + class is exactly the #528/#529 failure mode this design closes (see + _protectedlib.py:13-19) — a CONFIGURATION error (a mis-added registry + entry), so it must fail LOUDLY as a test failure here. + + Correction (finding F8, #564 follow-up): this docstring used to claim + an overlap "resolves silently by classify_protected's evaluation-order + precedence". Tracing every overlap case shows the opposite: `hits` is a + `set` that `classify_protected` accumulates every hit into (never a + first-match-wins dispatch), and every downstream consumer (pre-write.py/ + pre-edit.py) checks each class INDEPENDENTLY — so an overlapping entry + makes H-22 (and whichever legacy guard it also collides with) fail + STRICTLY CLOSED, both gates firing, never looser than either alone. This + test exists because a mis-added overlapping entry is still a + configuration bug worth catching loudly (two guards blocking the exact + same write for two different reasons is confusing and a maintenance + trap), not because it would silently under-protect anything.""" + + def _assert_no_overlap(self, rel_path): + self.assertFalse(_protectedlib.is_audit_log(rel_path), + f"{rel_path} collides with the audit class") + self.assertFalse(_protectedlib.is_decisions_path(rel_path), + f"{rel_path} collides with the decisions class") + self.assertFalse(_protectedlib.is_context_md(rel_path), + f"{rel_path} collides with the context class") + self.assertFalse(_protectedlib.is_marker_path(rel_path), + f"{rel_path} collides with the marker class") + + def test_no_legacy_overlap_synthetic_registry(self): + # The three named B2 consumers, checked ahead of their own + # enrolment tasks (T-33/T-65/T-66) — a synthetic registry lets this + # guard be meaningful NOW rather than only once those tasks land. + registry = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + ".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY, + ".codearbiter/done-tasks.md": ProtectedPolicy.APPEND_ONLY, + } + for rel_path in registry: + with self.subTest(rel_path=rel_path): + self._assert_no_overlap(rel_path) + + def test_no_legacy_overlap_is_self_arming(self): + # F8: the synthetic test above pins the THREE currently-named + # consumers as hardcoded literals — if a FOURTH, DIFFERENT entry is + # registered at enrolment, nothing forces anyone to remember to add + # its literal path there too. This test instead walks the LIVE + # `_protectedstatelib.REGISTRY` whenever it is non-empty, falling + # back to the same three-consumer set only while REGISTRY still + # ships empty (today). Once ANY consumer is registered for real, + # this test starts walking the REAL registry automatically, with NO + # code change needed here — closing the gap where a fourth/ + # different entry could ship unchecked. + registry = _protectedstatelib.REGISTRY or { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + ".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY, + ".codearbiter/done-tasks.md": ProtectedPolicy.APPEND_ONLY, + } + # The guard against ITS OWN vacuousness: a mutant that drops the + # `or {...}` fallback (leaving `registry = _protectedstatelib. + # REGISTRY`) makes `registry` the real, currently-EMPTY dict, and + # this assertion fails loudly instead of silently walking zero + # subtests the way the old test_no_legacy_overlap_default_registry + # did. + self.assertTrue(registry, "the overlap guard must never walk an empty set") + for rel_path in registry: + with self.subTest(rel_path=rel_path): + self._assert_no_overlap(rel_path) + + +class TestResolveRegisteredPath(unittest.TestCase): + """T-06/T-07: the flank-shared resolver. Once classify_protected has + already reported "state" for a target, this resolves WHICH registered + path matched and WHICH policy it carries — trying the SAME raw and + realpath-resolved forms classify_protected itself tries (#162), never a + second, independent membership check.""" + + def test_resolves_a_registered_relative_path(self): + registry = {".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED} + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + rel, policy = resolve_registered_path(target, root, registry) + self.assertEqual(rel, ".codearbiter/release-targets.md") + self.assertEqual(policy, ProtectedPolicy.MARKER_GATED) + + def test_unregistered_path_resolves_to_none_none(self): + registry = {".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED} + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "open-tasks.md") + self.assertEqual(resolve_registered_path(target, root, registry), (None, None)) + + def test_default_registry_resolves_nothing(self): + # The real, empty production REGISTRY. + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, ".codearbiter", "release-targets.md") + self.assertEqual(resolve_registered_path(target, root), (None, None)) + + @unittest.skipUnless(_symlinks_supported(), "symlink creation not permitted here") + def test_resolves_a_symlinked_protected_file_via_the_raw_leg(self): + # F3 (#564 follow-up): when the PROTECTED PATH ITSELF is a symlink + # pointing somewhere UNREGISTERED, realpath resolves the only + # spelling a host actually sends (the absolute path) straight + # through it to that unregistered target - the raw + # (symlink-unresolved) leg is what still recognizes the registered + # NAME. Before the fix this leg was `norm_path(fpath)` (an absolute + # path, never equal to a repo-relative registry key) and was + # inert - dropping it entirely was undetectable. + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + with tempfile.TemporaryDirectory() as root: + os.makedirs(os.path.join(root, ".codearbiter")) + decoy = os.path.join(root, "decoy.md") + with open(decoy, "w", encoding="utf-8") as f: + f.write("not registered\n") + link = os.path.join(root, ".codearbiter", "open-tasks.md") + os.symlink(decoy, link) + rel, policy = resolve_registered_path(link, root, registry) + self.assertEqual(rel, ".codearbiter/open-tasks.md") + self.assertEqual(policy, ProtectedPolicy.HELPER_ONLY) + + @unittest.skipUnless(_symlinks_supported(), "symlink creation not permitted here") + def test_resolves_a_symlinked_directory_alias_via_the_realpath_leg(self): + # The mirror-image #162 case, still needed alongside the raw leg + # above: a symlinked DIRECTORY whose visible path lacks + # ".codearbiter/" but resolves into it. Only the realpath-resolved + # leg sees this one - the raw leg computes "alias/open-tasks.md", + # which is not registered. + registry = {".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY} + with tempfile.TemporaryDirectory() as root: + ca = os.path.join(root, ".codearbiter") + os.makedirs(ca) + alias = os.path.join(root, "alias") + os.symlink(ca, alias, target_is_directory=True) + target = os.path.join(alias, "open-tasks.md") + rel, policy = resolve_registered_path(target, root, registry) + self.assertEqual(rel, ".codearbiter/open-tasks.md") + self.assertEqual(policy, ProtectedPolicy.HELPER_ONLY) + + +class TestStateWriteRes(unittest.TestCase): + """T-08: `_state_write_res`'s per-entry regex TEMPLATE and + `_build_state_write_res`'s registry walk, exercised directly — a regex + regression in the template itself is pinned independent of the + dispatch logic (`_check_h22_state`) built around it.""" + + def test_redirect_re_matches_single_and_double_chevron(self): + redirect_re, _, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertTrue(redirect_re.search("> open-tasks.md")) + self.assertTrue(redirect_re.search(">> open-tasks.md")) + + def test_write_re_matches_a_write_verb_followed_by_the_basename(self): + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertTrue(write_re.search("tee open-tasks.md")) + self.assertTrue(write_re.search("rm .codearbiter/open-tasks.md")) + + def test_write_re_excludes_git_verbs(self): + # B-07: the verb list must never include a git verb, or `git add + # open-tasks.md` (commit-gate Phase 7, run on every retained board + # flip) would make commit-gate block itself. + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertFalse(write_re.search("git add open-tasks.md")) + + def test_write_re_does_not_match_across_a_pipe_or_semicolon(self): + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertFalse(write_re.search("tee /tmp/x; echo open-tasks.md")) + + def test_write_re_is_case_insensitive(self): + # F1: mirrors lookup_policy's case-insensitivity so the two flanks + # agree. + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertTrue(write_re.search("tee OPEN-TASKS.MD")) + + def test_write_re_does_not_match_trailing_garbage_after_the_basename(self): + # F4 (#564 follow-up): the right-edge anchor (mirroring + # DECISION_LOG_SHELL_RE, #528) closes the over-match where the + # basename was merely a PREFIX-substring of a longer filename - + # `rm .codearbiter/open-tasks.md.bak` used to match. + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertFalse(write_re.search("rm .codearbiter/open-tasks.md.bak")) + + def test_write_re_still_matches_when_followed_by_a_redirect_or_quote(self): + # Non-regression for the right-edge anchor: it must not ALSO reject + # legitimate trailing punctuation immediately after the basename. + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertTrue(write_re.search("cp open-tasks.md /tmp/x")) + self.assertTrue(write_re.search('rm "open-tasks.md"')) + + def test_write_re_basename_escaping_is_load_bearing(self): + # F4: `re.escape` on the basename must actually run - a mutant + # dropping it turns the basename's own "." into "matches any + # character", which would let a DIFFERENT filename in the same + # position (any single char standing in for the dot) match too. + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertFalse(write_re.search("rm open-tasksXmd")) + + def test_write_re_includes_the_precedent_verbs(self): + # F6: sponge (LOG_DESTROY_RE precedent), ln, install, patch, shred. + _, write_re, _, _ = _bashguardlib._state_write_res("open-tasks.md") + for verb in ("sponge", "ln", "install", "patch", "shred"): + with self.subTest(verb=verb): + self.assertTrue(write_re.search(f"{verb} open-tasks.md")) + + def test_git_restore_re_matches_checkout_and_restore(self): + # F5: mirrors H-05's LOG_GIT_RESTORE_RE (#335) - checkout/restore + # rewrite a tracked worktree file through git itself, bypassing + # every filesystem verb above. + _, _, git_restore_re, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertTrue(git_restore_re.search("git checkout -- open-tasks.md")) + self.assertTrue(git_restore_re.search("git restore open-tasks.md")) + + def test_git_restore_re_excludes_git_add(self): + # F5's own non-regression: must not catch `git add` (B-07, + # commit-gate Phase 7 runs this on every retained board flip). + _, _, git_restore_re, _ = _bashguardlib._state_write_res("open-tasks.md") + self.assertFalse(git_restore_re.search("git add open-tasks.md")) + + def test_interp_re_matches_a_python_c_one_liner(self): + # F6: mirrors GATE_MARKER_INTERP_RE (#237) - the sanctioned + # helper's own Python file-I/O route, reused directly. + _, _, _, interp_re = _bashguardlib._state_write_res("open-tasks.md") + self.assertTrue( + interp_re.search("python3 -c \"open('open-tasks.md','w').write('x')\"")) + + def test_interp_re_matches_across_a_newline_in_the_payload(self): + # F6: per the #237 follow-up, this needs [\s\S]* (DOTALL-equivalent) + # not [^\n]* - the interpreter token and the filename may sit on + # different physical lines of the SAME multi-line -c payload. + _, _, _, interp_re = _bashguardlib._state_write_res("open-tasks.md") + payload = "python -c \"x = 1\nopen('open-tasks.md', 'w')\"" + self.assertTrue(interp_re.search(payload)) + + def test_interp_re_does_not_match_an_unrelated_interpreter_invocation(self): + _, _, _, interp_re = _bashguardlib._state_write_res("open-tasks.md") + self.assertFalse(interp_re.search("python3 -c \"print('hello world')\"")) + + def test_build_state_write_res_keys_off_bare_basename_per_entry(self): + built = _bashguardlib._build_state_write_res({ + ".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY, + ".codearbiter\\release-targets.md": ProtectedPolicy.MARKER_GATED, + }) + self.assertEqual(len(built), 2) + for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in built: + if rel_path.endswith("open-tasks.md"): + self.assertEqual(policy, ProtectedPolicy.HELPER_ONLY) + self.assertTrue(write_re.search("tee open-tasks.md")) + else: + self.assertEqual(policy, ProtectedPolicy.MARKER_GATED) + self.assertTrue(write_re.search("tee release-targets.md")) + + def test_build_state_write_res_reflects_an_empty_registry(self): + self.assertEqual(_bashguardlib._build_state_write_res({}), ()) + + def test_module_state_write_res_reflects_the_real_default_registry(self): + # The real production REGISTRY is empty at this slice, so the + # module-level, import-time-compiled tuple must be empty too. + self.assertEqual(_bashguardlib._STATE_WRITE_RES, ()) + + +def _comparable_state_res(built): + """A regex-object-free, `==`-comparable projection of a + `_build_state_write_res` tuple: compiled `re.Pattern` objects compare by + IDENTITY, never by pattern text, so two independently-built tuples with + IDENTICAL regex source never compare `==` even when they are behaviorally + identical. F9 needs to compare two SEPARATELY built tuples, so it needs + this projection to do it meaningfully.""" + return tuple( + (rel_path, policy, redirect_re.pattern, redirect_re.flags, + write_re.pattern, write_re.flags, + git_restore_re.pattern, git_restore_re.flags, + interp_re.pattern, interp_re.flags) + for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in built + ) + + +class TestStateWriteResReflectsRegistry(unittest.TestCase): + """F9 (#564 follow-up): `_STATE_WRITE_RES` must be GENUINELY DERIVED + from `_protectedstatelib.REGISTRY` at import time, not merely equal to + `()` because both happen to be empty today. + `test_module_state_write_res_reflects_the_real_default_registry` above + (`_STATE_WRITE_RES == ()`) is a VACUOUS pin for this specific mutant: a + mutant hardcoding `_STATE_WRITE_RES = ()` survives it for as long as the + real registry stays empty (T-33/T-65/T-66 not yet landed) - the + assertion is true regardless of whether the RHS was actually derived + from anything. + + Proves the derivation itself by RELOADING `_bashguardlib` against a + SYNTHETIC non-empty registry patched onto `_protectedstatelib` first - + the same "exercise the real default/import-time path against an + injected value" technique + `TestRegistryLookup.test_registry_lookup_reads_the_default_module_registry` + already uses for `lookup_policy`, extended to an import-time constant + that a per-call default parameter trick cannot reach.""" + + def test_state_write_res_is_genuinely_rebuilt_from_the_registry_at_import(self): + import importlib + original = _protectedstatelib.REGISTRY + try: + _protectedstatelib.REGISTRY = { + ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED, + } + reloaded = importlib.reload(_bashguardlib) + # Built fresh from a plain function call - untouched by a + # mutation that only targets the MODULE-LEVEL assignment line. + expected = reloaded._build_state_write_res(_protectedstatelib.REGISTRY) + self.assertNotEqual(expected, ()) + self.assertEqual( + _comparable_state_res(reloaded._STATE_WRITE_RES), + _comparable_state_res(expected), + ) + finally: + _protectedstatelib.REGISTRY = original + importlib.reload(_bashguardlib) + + +class _StateShellFixture(unittest.TestCase): + """In-process shell-flank harness for the H-22 protected-state check + (T-08/T-08a/T-08b). REGISTRY ships EMPTY at this slice (T-33/T-65/T-66 + enroll the three named consumers later); the compiled per-entry regex + set (`_bashguardlib._STATE_WRITE_RES`) is built ONCE at IMPORT from the + module registry (performance-002/_scopelib.py:109-117 precedent), so + exercising the real dispatch logic needs that compiled tuple rebuilt + against a SYNTHETIC registry — mutating `_protectedstatelib.REGISTRY` + after the fact would not be seen by it at all (it is a one-time + snapshot, by design).""" + + def setUp(self): + self._orig_state_res = _bashguardlib._STATE_WRITE_RES + self._tmp = tempfile.TemporaryDirectory() + self.root = os.path.join(self._tmp.name, "repo") + os.makedirs(os.path.join(self.root, ".codearbiter", ".markers")) + # Isolate block()'s gate-events append: with no CLAUDE_PROJECT_DIR + # override, project_root() would otherwise climb from THIS TEST + # PROCESS's cwd (this repo's own checkout) and append a live BLOCK + # line to the real .codearbiter/gate-events.log — the same + # isolation every subprocess-based hook test already applies via + # env=, needed here too since this harness calls the guard + # IN-PROCESS (a synthetic registry cannot cross a subprocess + # boundary — a fresh interpreter would just re-import the real, + # empty one). + self._orig_env = os.environ.get("CLAUDE_PROJECT_DIR") + os.environ["CLAUDE_PROJECT_DIR"] = self.root + + def tearDown(self): + _bashguardlib._STATE_WRITE_RES = self._orig_state_res + if self._orig_env is None: + os.environ.pop("CLAUDE_PROJECT_DIR", None) + else: + os.environ["CLAUDE_PROJECT_DIR"] = self._orig_env + self._tmp.cleanup() + + def _set_registry(self, registry): + _bashguardlib._STATE_WRITE_RES = _bashguardlib._build_state_write_res(registry) + + def _touch_marker(self, name, age_seconds=0): + m = os.path.join(self.root, ".codearbiter", ".markers", name) + with open(m, "w", encoding="utf-8") as f: + f.write("active\n") + if age_seconds: + past = time.time() - age_seconds + os.utime(m, (past, past)) + return m + + def _run_check(self, cmd): + """Run `_check_h22_state` with stderr captured (block() prints + there), returning (SystemExit|None, stderr_text) — keeps the + BLOCKED banner out of the test runner's own console output while + still letting a failure assertion show it for diagnosis.""" + old_stderr = sys.stderr + sys.stderr = io.StringIO() + try: + try: + _bashguardlib._check_h22_state(cmd, self.root) + return None, sys.stderr.getvalue() + except SystemExit as exc: + return exc, sys.stderr.getvalue() + finally: + sys.stderr = old_stderr + + def assertShellBlocked(self, cmd, tag="H-22"): + exc, err = self._run_check(cmd) + self.assertIsNotNone(exc, f"expected BLOCK for {cmd!r}, but it was allowed") + self.assertEqual(exc.code, 2, err) + self.assertIn(f"[{tag}]", err, err) + + def assertShellAllowed(self, cmd): + # _check_h22_state never sys.exit(0)s on its own — only run_guards' + # final fall-through does that — so "did not raise" IS the allow + # signal for this one check. + exc, err = self._run_check(cmd) + self.assertIsNone(exc, f"expected ALLOW for {cmd!r}; got {err!r}") + + +class TestStateShellMarkerGated(_StateShellFixture): + """B-04/T-08: marker-gated admits only under a fresh authoring marker.""" + + def setUp(self): + super().setUp() + self._set_registry({".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED}) + + def test_redirect_without_marker_blocks(self): + self.assertShellBlocked(">> .codearbiter/release-targets.md") + + def test_write_verb_without_marker_blocks(self): + self.assertShellBlocked("tee .codearbiter/release-targets.md") + + def test_write_verb_with_fresh_marker_admits(self): + self._touch_marker("release-targets-authoring") + self.assertShellAllowed("tee .codearbiter/release-targets.md") + + def test_write_verb_with_stale_marker_blocks(self): + self._touch_marker("release-targets-authoring", + age_seconds=MARKER_FRESHNESS_MINUTES * 60 + 60) + self.assertShellBlocked("tee .codearbiter/release-targets.md") + + def test_unrelated_command_passes(self): + self.assertShellAllowed("git status") + + def test_git_checkout_without_marker_blocks(self): + # F5: the git-restore leg feeds into the SAME policy dispatch as + # every other leg — marker-gated still requires a fresh marker. + self.assertShellBlocked("git checkout -- .codearbiter/release-targets.md") + + def test_git_checkout_with_fresh_marker_admits(self): + self._touch_marker("release-targets-authoring") + self.assertShellAllowed("git checkout -- .codearbiter/release-targets.md") + + +class TestStateShellHelperOnly(_StateShellFixture): + """B-05: helper-only is hard-blocked, with NO marker path at all — and + the B-07/B-08/B-09/B-12 non-regressions this policy's shell flank must + hold, all in one place.""" + + def setUp(self): + super().setUp() + self._set_registry({".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY}) + + def test_truncating_redirect_blocks_unconditionally(self): + self.assertShellBlocked("> open-tasks.md") + + def test_append_redirect_blocks_unconditionally(self): + # B-09 non-regression: `>> open-tasks.md` must BLOCK — helper-only + # carries no append allowance at this shell flank (that lives + # inside the sanctioned helper's own Python file I/O only). + self.assertShellBlocked(">> open-tasks.md") + + def test_tee_blocks_unconditionally(self): + # B-09 non-regression. + self.assertShellBlocked("tee open-tasks.md") + + def test_marker_never_admits_helper_only(self): + # helper-only has NO marker path at all — minting a marker, even + # one shaped like a marker-gated authoring token, must not admit. + self._touch_marker("open-tasks-authoring") + self.assertShellBlocked("tee open-tasks.md") + + def test_git_add_passes(self): + # B-07 non-regression — commit-gate Phase 7 runs exactly this on + # every retained board flip; a git verb in the write-verb set would + # make commit-gate block itself on its own sanctioned staging. + self.assertShellAllowed("git add open-tasks.md") + + def test_filename_as_helper_argv_data_passes(self): + # B-08 non-regression — the filename appears only as free-text argv + # DATA to the sanctioned helper, with no adjacent write verb. + self.assertShellAllowed('taskwrite add -- "fix open-tasks.md schema"') + + def test_taskwrite_invocation_passes_with_enrolment_live(self): + # B-12 circularity proof: the helper's own argv never lexically + # names the file it writes (core/surface/commands/task.md's + # invocation shape) — proved here rather than assumed, with + # helper-only actually wired and simulated as enrolled. + self.assertShellAllowed( + 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/taskwrite.py" add -- "ship the thing" ' + '|| python "${CLAUDE_PLUGIN_ROOT}/hooks/taskwrite.py" add -- "ship the thing"' + ) + + def test_verb_in_description_residual_false_blocks(self): + # T-08b: pin the documented lexical residual. A write verb ("tee") + # inside a free-text description, followed (no |;& between) by the + # protected basename later in the SAME command, is + # indistinguishable at this guard's lexical level from a genuine + # `tee open-tasks.md` redirect. This is EXPECTED, current behavior + # — not a bug to chase with smarter parsing (T-08b design ruling; + # /ca:override is the sanctioned escape hatch for a false block). + self.assertShellBlocked('taskwrite add -- "remember to tee open-tasks.md"') + + def test_verb_in_description_residual_pinned_passing_form(self): + # The B-08 non-regression this residual sits beside: the SAME + # filename, in the SAME free-text argv position, passes as long as + # no write-verb word happens to precede it in the command text. + self.assertShellAllowed('taskwrite add -- "fix open-tasks.md schema"') + + def test_git_checkout_blocks(self): + # F5, through the full dispatch: `git checkout` rewrites a tracked + # protected-state file through git itself, bypassing every + # filesystem verb above. + self.assertShellBlocked("git checkout -- open-tasks.md") + + def test_git_restore_blocks(self): + self.assertShellBlocked("git restore open-tasks.md") + + def test_python_c_one_liner_blocks(self): + # F6: reuses the sanctioned helper's own Python file-I/O route + # while naming the file lexically. + self.assertShellBlocked( + "python3 -c \"open('open-tasks.md','w').write('forged')\"") + + def test_sponge_blocks(self): + self.assertShellBlocked("sponge open-tasks.md") + + def test_shred_blocks(self): + self.assertShellBlocked("shred open-tasks.md") + + +class TestStateShellAppendOnly(_StateShellFixture): + """B-06/B-05 (T-13/T-65 ruling): append-only is flank-IDENTICAL to + helper-only at the shell guard — no tail-anchored or append-verb + admission here; the distinction lives entirely in the archive verb the + sanctioned helper exposes, never in this lexical check.""" + + def setUp(self): + super().setUp() + self._set_registry({".codearbiter/done-tasks.md": ProtectedPolicy.APPEND_ONLY}) + + def test_append_redirect_blocks_unconditionally(self): + self.assertShellBlocked(">> done-tasks.md") + + def test_tee_blocks_unconditionally(self): + self.assertShellBlocked("tee done-tasks.md") + + def test_git_add_passes(self): + self.assertShellAllowed("git add done-tasks.md") + + +class TestStateShellWiring(_StateShellFixture): + """T-08: `_check_h22_state` is actually WIRED into `run_guards()` — every + other test in this module calls `_check_h22_state` directly, which would + stay green even if the wiring line inside `run_guards()` itself were + deleted. This drives the full `run_guards()` entry point instead, the + same call pre-bash.py's `_run` makes, so a removed wiring line fails + HERE and nowhere else in this module.""" + + def setUp(self): + super().setUp() + self._set_registry({".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY}) + + def test_run_guards_blocks_through_the_real_entry_point(self): + payload = {"tool_name": "Bash", "tool_input": {"command": "tee open-tasks.md"}} + ti = {"command": "tee open-tasks.md"} + old_stderr = sys.stderr + sys.stderr = io.StringIO() + try: + with self.assertRaises(SystemExit) as ctx: + _bashguardlib.run_guards(payload, self.root, ti) + err = sys.stderr.getvalue() + finally: + sys.stderr = old_stderr + self.assertEqual(ctx.exception.code, 2, err) + self.assertIn("H-22", err, err) + + def test_run_guards_allows_an_unrelated_command_through_the_real_entry_point(self): + payload = {"tool_name": "Bash", "tool_input": {"command": "echo hi"}} + ti = {"command": "echo hi"} + with self.assertRaises(SystemExit) as ctx: + _bashguardlib.run_guards(payload, self.root, ti) + self.assertEqual(ctx.exception.code, 0) + + +class TestMarkerTouchAllowed(_StateShellFixture): + """T-08a: touching an AUTHORING marker must pass the shell flank, even + with H-22 actively enforcing its consumer file — the marker's own + basename ("release-targets-authoring") never collides with the + protected file's basename ("release-targets.md") under either the + redirect or write-verb pattern, and `touch` is not in H-22's + write-verb list at all (mirrors GATE_MARKER_WRITE_RE/CONTEXT_WRITE_RE's + own exclusion of `touch`). See the GATE_MARKER_NAMES comment in + _protectedlib.py for the "block-to-allow" criterion this pins.""" + + def setUp(self): + super().setUp() + self._set_registry({".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED}) + + def test_marker_touch_allowed(self): + self.assertShellAllowed("touch .codearbiter/.markers/release-targets-authoring") + + def test_marker_touch_allowed_against_the_real_pre_bash_subprocess(self): + # Exercises the REAL default (empty) registry through the actual + # hook entry point, not only the injected in-process harness above + # (the "at least one test against the real default path" review + # note) — and is also the simplest proof that nothing ELSE in the + # shell flank (GATE_MARKER_*, H-05, H-11, H-18) treats + # marker-touching as a blockable act. + ctx = os.path.join(self.root, ".codearbiter", "CONTEXT.md") + with open(ctx, "w", encoding="utf-8") as f: + f.write("---\narbiter: enabled\nstage: 2\n---\n\n") + payload = ('{"tool_name": "Bash", "tool_input": {"command": ' + '"touch .codearbiter/.markers/release-targets-authoring"}}') + env = {**os.environ, "CLAUDE_PROJECT_DIR": self.root} + res = subprocess.run([sys.executable, PRE_BASH], cwd=self.root, + input=payload, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=30, env=env) + self.assertEqual(res.returncode, 0, res.stderr) + + if __name__ == "__main__": unittest.main() From 6a903e6582708b1b3b0ab7f43ef166f90773d941 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Fri, 31 Jul 2026 03:44:58 -0400 Subject: [PATCH 04/66] docs(decisions): ratify ADR-0024, the protected-state executable-input boundary (#564) release-targets.md carries per-row pre-tag shell commands that /ca:release executes, which makes it executable input with no recorded trust model. ADR-0002 already governs that class for plan.json, but citing it would have been wrong on three counts, so the ADR names them: the model drafts these rows where plan.json was operator-typed, a consumer repo guarantees no PR review, and this is the only executable file under .codearbiter/ -- and the location chosen for it, on context economy grounds, does not inherit CONTEXT.md's guard. Protection is by write-gating rather than content inspection. No content predicate ever grants admission: a guard that reads a file to decide whether to permit writing it converts content into an authorization signal, launderable by anyone who can write the content. That is the same defect that sank the file-absent exemption and the conflict-marker carve-out earlier in this campaign. The marker is audit friction, not authorization. It is self-mintable by shell under ADR-0010, and GATE_MARKER_NAMES is deliberately not widened to cover it -- doing so would brick every minting lane while stopping no non-cooperative agent. Four residuals are declared rather than left undeclared, including the touch exclusion with both reviewers' positions recorded. Three reopen conditions are named; the sharpest is that recurring board-conflict overrides mean building a taskwrite resolve verb, never an exception in the guard. Closes T-16. Refs: #564, DECISION-0035, ADR-0002, ADR-0010 --- ...tate-declared-executable-input-boundary.md | 72 +++++++++++++++++++ .codearbiter/decisions/decision-log.md | 25 +++++++ .../portable-release-and-protected-state.md | 16 ++--- 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 .codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md diff --git a/.codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md b/.codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md new file mode 100644 index 00000000..47cb1946 --- /dev/null +++ b/.codearbiter/decisions/0024-protected-state-declared-executable-input-boundary.md @@ -0,0 +1,72 @@ +--- +status: accepted +date: 2026-07-31 +title: Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers +decided-by: SUaDtL@users.noreply.github.com +supersedes: none +governs: core/pysrc/_protectedstatelib.py, core/pysrc/_protectedlib.py, core/pysrc/_bashguardlib.py, .codearbiter/release-targets.md +--- + +# ADR-0024 — Protected-state registry is a declared executable-input boundary with cooperative, friction-grade markers + +## Status + +Accepted — ratified 2026-07-31 by SUaDtL@users.noreply.github.com. Content pre-approved at the sprint's Phase 1 gate the same day. + +## Context + +`H-22` introduces a registry of protected project-state files, each carrying a policy — `marker-gated`, `helper-only`, or `append-only` — enforced across `pre-write.py`, `pre-edit.py`, and `_bashguardlib.py`. Its first consumer, `.codearbiter/release-targets.md`, carries per-row `pre-tag` shell commands that `/ca:release` executes before composing a tag (DECISION-0034). + +That makes `release-targets.md` **executable input**: a repository file whose contents the framework runs. ADR-0002 already established the trust model for this class in `plan.json` — operator-authored, PR-reviewed, length-capped, with the boundary declared rather than an allowlist imposed. This ADR records where the new case matches that precedent and, more importantly, where it does not. + +Three differences from `plan.json` were raised in review and are named here rather than papered over with a citation: + +1. **Authorship.** ADR-0002 rests on "operator-authored". Here the model drafts the rows — `context-creation` elicits them, and a back-fill lane proposes a detected shape. Model-drafted shell later executed by the skill is a different trust class than operator-typed shell. +2. **Review discipline.** In a consumer repo nothing guarantees `release-targets.md` edits are PR-reviewed. A third-party PR touching it plants commands the maintainer's next `/ca:release` runs. +3. **Write protection.** It is the only *executable* file under `.codearbiter/`, and the location chosen for it — a separate file rather than a `CONTEXT.md` block, on context-economy grounds — does not inherit `CONTEXT.md`'s existing guard. + +## Decision + +The registry is a declared executable-input boundary, protected by **write-gating rather than content inspection**. + +- `release-targets.md` is registered `marker-gated`. Mutations are admitted only under a fresh `release-targets-authoring` marker, minted immediately before the write and removed at lane exit. +- Each `pre-tag` entry is capped at **1024 characters**, following ADR-0002's precedent. +- `pre-tag` commands are **check-only** and may never mutate the tree (DECISION-0034). The clean-tree assertion is unconditional, so a rogue command's writes surface before tagging. +- The resolved `pre-tag` list is content-hashed; a change forces re-confirmation, so a silently edited command cannot ride an earlier approval. +- **No content predicate ever grants admission.** A guard that reads file content to decide whether to permit a write converts content into an authorization signal, which is launderable by anyone who can write the content. + +**The marker is audit friction, not authorization.** It is self-mintable via shell by design, per ADR-0010's cooperative-attestation posture. Its value is the trail and the deliberate pause, not unforgeability. `GATE_MARKER_NAMES` — the shell-flank blocklist — enumerates only markers that convert a BLOCK into an ALLOW, and authoring markers are correctly outside it. Widening that list generically over every registered marker would brick every minting lane while stopping no non-cooperative agent, since such an agent shell-mints regardless. + +**Case handling is global, not host-derived.** Both flanks treat registered paths case-insensitively and tolerate a `./` prefix, trailing slash, doubled slash, and surrounding whitespace. Matching the host filesystem was rejected: case-sensitivity varies by platform *and* by volume on the same platform, and `realpath` cannot fold case for a path that does not yet exist — exactly a Write creating a protected file for the first time. A fixed rule both flanks apply without consulting the filesystem only widens what is protected, never narrows it. + +## Accepted residuals + +Declared here and in `security-controls.md`. Undeclared is not the same as accepted. + +- **Shell indirection.** `f=…; sed -i "$f"` and novel interpreter spellings evade the lexical flank. The same residual `CONTEXT.md` already carries under ADR-0010, inherent to a cooperative guard. +- **Lexical false blocks.** The shell flank matches a registered file's bare basename with no directory requirement — forced by the need to catch a bare `tee open-tasks.md`. A description containing a write verb and the filename false-blocks. `/ca:override` is the sanctioned bypass. +- **`touch` is excluded.** Two reviewers split. A security pass traced every mtime consumer under `.codearbiter/` and found none feeding an admission decision — `marker_gated_write_admitted` stats the *marker*, not the protected file, so even back-dating admits nothing. An adversarial pass countered that `DECISIONS_WRITE_RE` *does* include `touch`, because for H-11 creation itself is the violation, and that `touch` on an absent board creates an empty board outside the sanctioned helper. The admission analysis was judged decisive; the creation case is recorded as the known cost. +- **Merge-conflict resolution.** A conflict in a `helper-only` file has no helper verb, so resolution routes through logged `/ca:override`. + +## Reopen conditions + +- If `gate-events.log` shows board-conflict overrides recurring, build a `taskwrite resolve` verb. Never punch an exception into the guard. +- If `release-targets.md` ever ingests untrusted or third-party content, the operator-authored premise is void and this decision must be revisited — inheriting ADR-0002's own reopen trigger. +- If any registry entry ever needs a non-forgeable marker, that marker joins `GATE_MARKER_NAMES` deliberately and gains a sanctioned producer, as a reviewed one-line widening. + +## Alternatives considered + +- **A content allowlist on `pre-tag` commands** — rejected for the reason ADR-0002 rejected it: it over-engineers a trusted-operator input and risks refusing legitimate commands. +- **Uniform marker-gating for all three consumers** — rejected. Marker-gating `open-tasks.md` would *admit* an agent hand-composing board markdown under a marker, while its sanctioned helper is already invisible to every flank by construction. The correct policy there is a hard block with no marker path. +- **A disk-loaded registry** — rejected. It would let a consumer repo un-protect its own board by editing a file. +- **Storing the rows in `CONTEXT.md`** to inherit its write guard — rejected on context economy: `CONTEXT.md` is read every session, release configuration only when tagging. + +## Consequences + +Easier: an explicit trust model, so a reviewer knows `release-targets.md` is executable input and reviews it as such; and one registry that a fourth protected file joins as a row rather than as a new hook branch. + +Harder: correctness depends on write-gating and PR-review discipline rather than on content validation, and the lexical flank will occasionally false-block a legitimate command. + +## Risks + +A malicious or mistaken `pre-tag` entry runs arbitrary shell on the maintainer's host at release time. Accepted because the entry is write-gated, length-capped, check-only, content-hashed against silent change, and surfaced for confirmation. The residual is a cooperative-guard residual, not a sandbox. diff --git a/.codearbiter/decisions/decision-log.md b/.codearbiter/decisions/decision-log.md index d5b42163..f774af21 100644 --- a/.codearbiter/decisions/decision-log.md +++ b/.codearbiter/decisions/decision-log.md @@ -1164,3 +1164,28 @@ Five of six lenses favored check-only. Maintainable and Testable: one rule with `.codearbiter/release-targets.md` rows carry a `pre-tag` list with no assert-clean flag. The release skill runs each command, asserts exit 0, then asserts a clean tree, and BLOCKs on either failure. This repo's badge and count reconciliation must be expressed as check scripts — `check_badge_consistency.py` already has that shape; the catalog and README-table assertions need equivalent non-mutating checks written. `build-host-packages.py` is not a pre-tag command; a companion check asserts the generated root manifest matches the plugin manifest and fails when it lags. Costs this repo one extra loop per release when a generated artifact is stale. Tracked under issue #563, spec `specs/release-portable-fixture.md`. --- + +## DECISION-0035 — adr-0024-ratification — Protected-state registry ratified as a declared executable-input boundary + +**Date:** 2026-07-31 +**Status:** accepted +**Supersedes:** none +**Decided by:** SUaDtL@users.noreply.github.com +**Decision category:** security-architecture +**Artifact-section-hash:** n/a + +### Variance summary +- **Artifact position:** `.codearbiter/release-targets.md` carries per-row `pre-tag` shell commands that `/ca:release` executes, making it executable input, with no recorded trust model. +- **Scaffold position:** ADR-0002 already governs this class for `plan.json` — operator-authored, PR-reviewed, length-capped, boundary declared rather than allowlisted — but three material differences separate the new case from it. +- **Status type:** open-decision-closure + +### Decision +ADR-0024 is accepted. The protected-state registry is a declared executable-input boundary protected by write-gating rather than content inspection: marker-gated writes, a 1024-character cap per `pre-tag` entry, check-only commands under an unconditional clean-tree assertion, and a content hash forcing re-confirmation when a command changes. No content predicate ever grants admission. The authoring marker is explicitly audit friction rather than authorization, self-mintable by design under ADR-0010, and `GATE_MARKER_NAMES` is not widened to cover it. Case handling is global rather than host-derived. + +### SMARTS rationale +Securable drove it, and the decisive reasoning is that the alternatives fail in the same shape: a content predicate admitting a write based on what the file contains converts content into an authorization signal, launderable by anyone who can write the content — the same defect that sank the file-absent exemption and the conflict-marker carve-out considered earlier in this campaign. Maintainable and Scalable favored one registry over per-file hook branches, and the maintainer's standing steer to weight `Scalable` heavily for deterministic enforcement over prose reinforced it. Reliable favored global case handling once it was established that `realpath` cannot fold case for a file that does not yet exist, which is precisely a Write creating a protected file for the first time — a host-derived rule would have been silently wrong exactly at creation. + +### Implementation implication +`core/pysrc/_protectedstatelib.py`, `_protectedlib.py` and `_bashguardlib.py` carry the enforcement, already landed at 56387ee. The ADR's `governs:` field enrolls those three plus `.codearbiter/release-targets.md`, so the post-write hook surfaces the decision at edit time rather than at a checkpoint sweep. Four residuals are declared in both the ADR and `security-controls.md`, and three reopen conditions are recorded — most concretely, that recurring board-conflict overrides in `gate-events.log` mean building a `taskwrite resolve` verb rather than punching an exception into the guard. Closes T-16 of the sprint plan. + +--- diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md index a859766f..18a1f2e0 100644 --- a/.codearbiter/plans/portable-release-and-protected-state.md +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -142,14 +142,14 @@ Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, sh an authoring marker fakes nothing and is self-mintable by design (ADR-0010). The risk runs opposite to intuition — a future generic "hardening" over every registered marker would brick every minting lane while stopping no non-cooperative agent. T-08a is the fence against that. -| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_git_add_passes` — `git add open-tasks.md` passes. Load-bearing: `commit-gate` Phase 7 runs exactly that on every retained board flip, so a git verb in the list makes commit-gate block itself | git-verb non-regression | B-07 | T-08 | PENDING | -| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_filename_as_helper_argv_data_passes` — helper call with the filename in its description passes | argv-data non-regression | B-08 | T-08 | PENDING | -| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_tee_blocks_unconditionally` and `-k test_append_redirect_blocks_unconditionally` | shell-write blocking | B-09 | T-08 | PENDING | -| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_taskwrite_invocation_passes_with_enrolment_live` — the real `core/surface/commands/task.md` invocation shape. **Not mutation-killable by construction** — the command names no registered basename, which is the property being proved; assert it against the generated invocation rather than a hand-copy so it notices drift | circularity proof | B-12 | T-08 | PENDING | -| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k TestStateShellAppendOnly` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | PENDING | -| T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | PENDING | -| T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | PENDING | -| T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | PENDING | +| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_git_add_passes` — `git add open-tasks.md` passes. Load-bearing: `commit-gate` Phase 7 runs exactly that on every retained board flip, so a git verb in the list makes commit-gate block itself | git-verb non-regression | B-07 | T-08 | ACCEPTED | +| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_filename_as_helper_argv_data_passes` — helper call with the filename in its description passes | argv-data non-regression | B-08 | T-08 | ACCEPTED | +| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_tee_blocks_unconditionally` and `-k test_append_redirect_blocks_unconditionally` | shell-write blocking | B-09 | T-08 | ACCEPTED | +| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_taskwrite_invocation_passes_with_enrolment_live` — the real `core/surface/commands/task.md` invocation shape. **Not mutation-killable by construction** — the command names no registered basename, which is the property being proved; assert it against the generated invocation rather than a hand-copy so it notices drift | circularity proof | B-12 | T-08 | ACCEPTED | +| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k TestStateShellAppendOnly` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | ACCEPTED | +| T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | ACCEPTED | +| T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | ACCEPTED | +| T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | ACCEPTED | **HARD GATE at T-16** — `/ca:adr` requires user attribution. Halts and surfaces. From 0664506d06a286ff0b4d7e219db2ab1457ef223d Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Fri, 31 Jul 2026 04:29:45 -0400 Subject: [PATCH 05/66] feat(release): split _releaselib into portable mechanism and declared data (#563) The release lane could not run in any repo that installs codeArbiter, because its first pre-flight step calls a helper living at .github/scripts/, outside the plugin payload. This moves the portable half into core/pysrc/, where sync-core materializes it byte-identically into all three governance plugins, and adds the parser for the declared target file that will carry what used to be hardcoded. Repo facts become required parameters rather than defaults: the merge-readiness check name, the tag prefix, the target list. A default is how a repo assumption survives a portability refactor unnoticed. An adversarial pass ran 46 mutants and found three HIGH, all fixed here. The parser truncated a block when a value contained the closing delimiter and that delimiter was the only close, parsing as success with a required key silently emptied. Empty and whitespace-only values satisfied the required-key check, because the row finisher tested "is None" while a parsed key always assigns a string, so a typo'd empty prefix became "" and flowed onward as "never released". And seven of the nine mechanism functions had no coverage in the new module at all: each could be replaced with a constant return while the whole suite stayed green, because the tests exercising those names ran against the old helper this slice deliberately left untouched. The suite appeared to cover the migration and covered the thing being migrated from. The portability bug worth naming: the pre-release filter substring-tested "-beta", "-rc" and "-alpha" against the whole tag including the prefix. Unreachable here, since the anchored regex already excludes suffixed tags. But a consumer with a web-beta-v prefix loses every tag in that series and gets the "never released" sentinel, so the lane offers a first-release baseline to a project that has releases. Invisible in this repo, live in theirs, sitting inside the code whose purpose is to remove exactly that class of defect. The manifest bump is separate but required: two earlier commits changed shipped payload while the version stayed at one already on the default branch with payload attached, so claude plugin update would have no-opped and the H-22 guard would have reached nobody. Refs: #563 --- .../portable-release-and-protected-state.md | 746 ++++++++-------- .../specs/release-portable-fixture.md | 21 +- .codearbiter/sprint-log.md | 1 + .github/scripts/test_release_lib.py | 832 ++++++++++++++++++ README.md | 2 +- core/pysrc/_releaselib.py | 670 ++++++++++++++ plugins/ca-codex/hooks/_releaselib.py | 670 ++++++++++++++ plugins/ca-pi/hooks/_releaselib.py | 670 ++++++++++++++ plugins/ca/.claude-plugin/plugin.json | 2 +- plugins/ca/hooks/_releaselib.py | 670 ++++++++++++++ 10 files changed, 3906 insertions(+), 378 deletions(-) create mode 100644 core/pysrc/_releaselib.py create mode 100644 plugins/ca-codex/hooks/_releaselib.py create mode 100644 plugins/ca-pi/hooks/_releaselib.py create mode 100644 plugins/ca/hooks/_releaselib.py diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md index 18a1f2e0..f2fd1879 100644 --- a/.codearbiter/plans/portable-release-and-protected-state.md +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -1,373 +1,373 @@ -# Plan — portable release + protected-state machinery - -**Spec:** `.codearbiter/specs/portable-release-and-protected-state.md` -**Companion spec:** `.codearbiter/specs/release-portable-fixture.md` (rev 4) -**Date:** 2026-07-31 -**Status column is the resume ledger** — `subagent-driven-development` flips a task to `ACCEPTED` on -acceptance; an interrupted run re-enters at the first non-`ACCEPTED` task. - -> **Verification note.** Every `unittest discover` verification MUST be run with `NO_COLOR` unset. -> This harness exports `NO_COLOR=1`, which makes `statusline.py` strip SGR and fails 7 palette tests -> against a healthy tree. Prefix with `Remove-Item Env:\NO_COLOR -ErrorAction SilentlyContinue;` in -> the same shell call. - -## AC ledger - -### Workstream A — release portability - -The companion spec is **authoritative**; criteria are cited by ID with a short label rather than -duplicated, so the two documents cannot drift. - -| AC | label | -|---|---| -| A-1.1 | `core/pysrc/_releaselib.py` exists; `sync-core --check` passes | -| A-1.2 | mechanism carries no repo-namespace literal (denylist) | -| A-1.3 | repo defaults become required params (`classify_merge_readiness`, `last_tag_select`, `select_release_target`) | -| A-1.4 | `load_targets(path)` returns the full row schema | -| A-1.5 | absent block → declared error | -| A-1.6 | each parser-contract violation → its own declared error (8 cases) | -| A-1.7 | empty block → declared error | -| A-1.8 | series isolation against loaded data | -| A-1.9 | *(transitional)* shim re-exports mechanism, still exposes `RELEASE_TAG_PREFIXES` | -| A-1.10 | this repo's four rows load; target+prefix equal pre-change constants | -| A-1.11 | resolution trace reproduces a recorded pre-change run for `ca` and `ca-pi` | -| A-2.1 | pre-tag commands execute in declared order | -| A-2.2 | non-zero exit blocks | -| A-2.3 | dirty tree blocks; assertion precedes `rebuild` | -| A-2.4 | >1024-char `pre-tag` entry rejected | -| A-2.5 | `security-controls.md` boundary-crossings entry | -| A-2.6 | protected class admits `release-targets.md` writes only under marker | -| A-2.7 | four-case flank test | -| A-2.8 | `check_command_catalog.py` exists and is non-mutating | -| A-2.9 | this repo's declared rows run green on a reconciled tree | -| A-2.10 | `pre-tag` content-hash change forces re-confirmation | -| A-3.1 | declared manifest → assert equality, BLOCK on mismatch | -| A-3.2 | no manifest → tag is version source | -| A-3.3 | `rebuild` runs; artifacts asserted clean; nondeterministic bundler named | -| A-3.4 | `payload-exclude` honored (ca-pi `tools/`) | -| A-3.5 | `provenance-manifest` optional; absent → skip + report | -| A-3.6 | interpreter fallback where `python3` absent | -| A-4.1 | `payload_version_gate.py` derives prefixes from declared file | -| A-4.2 | target selection name-keyed | -| A-4.3 | workflow-contract test on name agreement | -| A-4.4 | constants removed from shim; six sites + gate still pass; A-1.9 test retired | -| A-5.1 | `decompose` elicits intent only | -| A-5.2 | `context-creation` writes a file `load_targets` accepts | -| A-5.3 | back-fill presents and requires confirmation | -| A-5.4 | back-fill persists; second run reads | -| A-5.5 | first-release changelog baseline instead of per-commit BLOCK | -| A-5.6 | provenance triggers are the rows' referenced paths | -| A-6.1 | reference-form guard over `core/surface/skills/**` | -| A-6.2 | `subagent-driven-development` farm.js reference resolves | -| A-6.3 | `decision-lifecycle` reworded to conditional CI reference | -| A-6.4 | `commands/release.md` matches the skill | -| A-6.5 | docs-site guide distinguishes general lane from this repo | - -### Workstream B — protected-state machinery - -| AC | criterion | -|---|---| -| B-01 | The registry carries a per-entry policy enum `{marker-gated, helper-only, append-only}`, present from slice 1 | -| B-02 | `marker-gated`: a Write is admitted only under a fresh authoring marker | -| B-03 | `marker-gated`: the Edit flank blocks via `classify_protected` per-class dispatch | -| B-04 | `marker-gated`: the shell flank blocks via a redirect + write-verb regex pair | -| B-05 | `helper-only`: Write, Edit, and shell naming the file are hard-blocked with **no** marker path | -| B-06 | `append-only`: mutation is admitted only via the helper's append verb | -| B-07 | `git add open-tasks.md` passes the shell flank | -| B-08 | `taskwrite add -- "fix open-tasks.md schema"` passes (filename as argv data) | -| B-09 | `tee open-tasks.md` and `>> open-tasks.md` block | -| B-10 | A stale marker (older than the freshness window) does not admit a write | -| B-11 | The class carries a stable `H-NN` ID cited in code comments and its test | -| B-12 | With enrolment live, a `taskwrite.py` invocation still succeeds (circularity proof) | -| B-13 | `release-targets.md` is registered `marker-gated` | -| B-14 | `open-tasks.md` is registered `helper-only` | -| B-15 | `done-tasks.md` is registered `append-only` | -| B-16 | `debug/SKILL.md:80` writes via `{{CMD:task}} add` rather than a direct append | -| B-17 | `taskwrite add` supports the rationale sub-bullet `debug` requires | -| B-18 | `context-creation`'s board population routes through the helper or a declared scaffold-time exemption | -| B-19 | A done-flip still classifies RETAINED through `classify_board_diff` after enrolment | -| B-20 | `taskwrite archive ` appends to `done-tasks.md` then removes from `open-tasks.md`, per item | -| B-21 | `archive` is rerun-safe: dedup by dotted ID, exact text for ID-less entries | -| B-22 | Interruption mid-sweep leaves item-level consistency — no duplicate, no loss | -| B-23 | `done-tasks.md` is created with the expected header shape | -| B-24 | `/ca:standup` offers the sweep under per-item confirmation | -| B-25 | Cutoff is a named constant, default done > 14 days, tested with an injected date | -| B-26 | An undated `[x]` archives only under explicit per-item confirmation | -| B-27 | An ADR records the executable-input boundary and names the ADR-0010 shell-indirection residual | - -## Tasks - -Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, shared kernel in -`core/pysrc/`, hook tests in `plugins/ca/hooks/tests/`, standalone gates in `.github/scripts/`. -`SUITE` = `Remove-Item Env:\NO_COLOR -EA SilentlyContinue; python -m unittest discover -s plugins/ca/hooks/tests -p "test_*.py"`. - -### Step 1 — B1: the class and registry (MVP slice begins) - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-01 | `core/pysrc/_protectedstatelib.py` | `python -m py_compile` passes; module imports with zero side effects | registry module exists | B-01 | — | ACCEPTED | -| T-02 | `core/pysrc/_protectedstatelib.py`, `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_policy_enum` — all three policies present, unknown policy raises | policy enum | B-01 | T-01 | ACCEPTED | -| T-03 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_registry_lookup` — registered path returns its policy, unregistered returns None | registry lookup | B-01 | T-02 | ACCEPTED | -| T-04 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_gated_write` — fresh marker admits, absent marker blocks | marker-gated Write | B-02 | T-03 | ACCEPTED | -| T-05 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_stale` — marker older than the window blocks | marker freshness | B-10 | T-04 | ACCEPTED | -**Flank wiring design — proxy-ruled 2026-07-31, do not re-derive.** Full reasoning in `sprint-log.md`. - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-05a | `core/pysrc/_protectedlib.py` | `python .github/scripts/test_hooklib.py -k ClassifyProtectedStateTest` — `classify_protected` returns `"state"` for a registered path; return contract stays a set of strings so its four consumers see no change | classifier extension | B-01 | T-05 | ACCEPTED | -| T-05b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_no_legacy_overlap` — **no** registered path classifies into any legacy class; overlap fails loudly as a configuration error rather than resolving by precedence | overlap guard | B-01 | T-05a | ACCEPTED | -| T-06 | `core/pysrc/pre-write.py` | `SUITE -p "test_pre_write.py" -k TestH22ProtectedState` — one generic `"state"` branch resolves the entry's policy from the registry and applies it; no second lookup | pre-write flank | B-02, B-05 | T-05b | ACCEPTED | -| T-07 | `core/pysrc/pre-edit.py` | `SUITE -p "test_pre_edit.py" -k TestH22ProtectedState` — same generic branch; `helper-only` blocks **unconditionally**, no marker path | pre-edit flank | B-03, B-05 | T-06 | ACCEPTED | -| T-08 | `core/pysrc/_bashguardlib.py` | `SUITE -k TestStateShell` — `_state_write_res(basename) -> (redirect_re, write_re)` compiled once at import from the **code-constant** registry, per entry rather than one alternation. `TestStateShellWiring` must drive `run_guards()` itself: every other shell test calls `_check_h22_state` directly and would pass with the wiring deleted | shell flank | B-04, B-05 | T-07 | ACCEPTED | -| T-08a | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_marker_touch_allowed` — `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; `GATE_MARKER_NAMES` gains a comment stating it enumerates **block-to-allow** markers while friction markers stay touchable by design | minting fence | B-04 | T-08 | ACCEPTED | -| T-08b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_verb_in_description_residual` — documents that `taskwrite add -- "remember to tee open-tasks.md"` false-blocks; pins the **passing** B-08 form and records the residual rather than chasing it with smarter parsing | lexical residual | B-08 | T-08a | ACCEPTED | - -**Rulings encoded above, with the reasoning that produced them:** - -- **The registry is code constants, never disk-loaded.** A disk registry would let a consumer repo - un-protect `open-tasks.md` by editing a file. Zero-side-effects-at-import prohibits file I/O and - git, not regex compilation from module constants (`_scopelib.py:109-117` precedent). -- **Dispatch extends `classify_protected` rather than sitting beside it.** `_protectedlib.py:13-19` - records #528/#529, where independent class checks deadlocked because set membership was - uncoordinated; and #162 symlink laundering is closed *inside* the classifier - (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms). A parallel - lookup ships without symlink resolution, and a symlink alias writes through the guard on day one. -- **`helper-only` is unconditional, with merge conflicts as a named residual.** A conflict in - `open-tasks.md` itself has no `taskwrite` verb, so resolution routes through logged - `/ca:override`. The ADR carries a reopen condition: if `gate-events.log` shows board-conflict - overrides recurring, build a `taskwrite resolve` verb — never a guard exception. A - conflict-marker content predicate was rejected as converting file content into an authorization - signal. -- **`GATE_MARKER_NAMES` is not widened.** It enumerates markers that convert a BLOCK into an ALLOW; - an authoring marker fakes nothing and is self-mintable by design (ADR-0010). The risk runs - opposite to intuition — a future generic "hardening" over every registered marker would brick - every minting lane while stopping no non-cooperative agent. T-08a is the fence against that. -| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_git_add_passes` — `git add open-tasks.md` passes. Load-bearing: `commit-gate` Phase 7 runs exactly that on every retained board flip, so a git verb in the list makes commit-gate block itself | git-verb non-regression | B-07 | T-08 | ACCEPTED | -| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_filename_as_helper_argv_data_passes` — helper call with the filename in its description passes | argv-data non-regression | B-08 | T-08 | ACCEPTED | -| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_tee_blocks_unconditionally` and `-k test_append_redirect_blocks_unconditionally` | shell-write blocking | B-09 | T-08 | ACCEPTED | -| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_taskwrite_invocation_passes_with_enrolment_live` — the real `core/surface/commands/task.md` invocation shape. **Not mutation-killable by construction** — the command names no registered basename, which is the property being proved; assert it against the generated invocation rather than a hand-copy so it notices drift | circularity proof | B-12 | T-08 | ACCEPTED | -| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k TestStateShellAppendOnly` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | ACCEPTED | -| T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | ACCEPTED | -| T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | ACCEPTED | -| T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | ACCEPTED | - -**HARD GATE at T-16** — `/ca:adr` requires user attribution. Halts and surfaces. - -### Step 2 — A slices 1–4 (MVP slice continues through T-33) - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-17 | `core/pysrc/_releaselib.py` | `python tools/sync-core.py --check` passes | mechanism ships | A-1.1 | T-15 | PENDING | -| T-18 | `core/pysrc/_releaselib.py`, `.github/scripts/test_release_lib.py` | `python .github/scripts/test_release_lib.py -k denylist` — no repo literal | data-free mechanism | A-1.2 | T-17 | PENDING | -| T-19 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k required_params` — three functions reject missing args | required params | A-1.3 | T-18 | PENDING | -| T-20 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k load_targets` — full row schema from a fixture | loader | A-1.4 | T-19 | PENDING | -| T-21 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k absent_block` raises the declared error | absent-block error | A-1.5 | T-20 | PENDING | -| T-22 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k parser_contract` — 8 violations, 8 distinguishable errors | parser contract | A-1.6 | T-21 | PENDING | -| T-23 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k empty_block` raises the declared error | empty-block error | A-1.7 | T-22 | PENDING | -| T-24 | `.github/scripts/test_release_lib.py` | `-k series_isolation` — `v` and `ca-pi-v` resolve independently | series isolation | A-1.8 | T-23 | PENDING | -| T-25 | `.github/scripts/_releaselib.py` | `python .github/scripts/payload_version_gate.py --plugin plugins/ca --base origin/main` exits 0 (bare invocation exits 2 — args are required) | transitional shim | A-1.9 | T-24 | PENDING | -| T-26 | `.codearbiter/release-targets.md` | `python .github/scripts/test_release_lib.py -k this_repo_rows` — 4 rows load, prefixes match constants, **and every one of the four declares `provenance-manifest`** | repo rows declared | A-1.10 | T-25 | PENDING | -| T-27a | `.github/scripts/fixtures/release-trace/` | `python .github/scripts/test_release_trace.py -k fixture_shape` — frozen tag list, manifests, commit graph, 4 rows | trace fixture | A-1.11 | T-26 | PENDING | -| T-27b | `.github/scripts/test_release_trace.py` | `-k old_lane_loads` — helpers pinned via `git show :.github/scripts/_releaselib.py` | pinned old lane | A-1.11 | T-27a | PENDING | -| T-27c | `.github/scripts/test_release_trace.py` | `-k old_lane_live` — the transcribed old lane resolves `ca`'s real last tag against the live repo; **divergence is a STOP, not a fixup** | old-lane validation | A-1.11 | T-27b | PENDING | -| T-27d | `.github/scripts/test_release_trace.py` | `-k trace_matches` — new lane reproduces the recorded variable dict for `ca` **and** `ca-pi` | trace assertion | A-1.11 | T-27c | PENDING | -| T-28 | `core/surface/skills/release/SKILL.md` | `test_release_lib.py -k pre_tag_order` — declared order preserved | pre-tag order | A-2.1 | T-27 | PENDING | -| T-29 | `core/pysrc/_releaselib.py` | `-k pre_tag_exit` — non-zero exit blocks | pre-tag exit | A-2.2 | T-28 | PENDING | -| T-30 | `core/pysrc/_releaselib.py` | `-k pre_tag_dirty` — dirty tree blocks, assertion precedes rebuild | clean-tree gate | A-2.3 | T-29 | PENDING | -| T-31 | `core/pysrc/_releaselib.py` | `-k pre_tag_cap` — >1024 chars rejected | length cap | A-2.4 | T-30 | PENDING | -| T-32 | `.codearbiter/security-controls.md` | boundary-crossings entry present; `test_release_lib.py -k boundary_entry` | boundary declared | A-2.5 | T-31 | PENDING | -| T-33 | `core/pysrc/_protectedstatelib.py`, `.codearbiter/release-targets.md` | `SUITE -k test_release_targets_registered` — marker-gated, 4-case flank test passes | consumer 1 enrolled | A-2.6, A-2.7, B-13 | T-32 | PENDING | - -**HARD GATE at T-32** — `security-controls.md` is a trust-boundary change. - -**— END MVP SLICE —** At T-33 the registry exists with a live consumer, the mechanism ships, this -repo's rows load, and the release lane is proven behavior-identical. Shippable on its own. - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-34 | `.github/scripts/check_command_catalog.py` | script exits 0 on a reconciled tree, 1 on drift, mutates nothing | catalog check | A-2.8 | T-33 | PENDING | -| T-35 | `.codearbiter/release-targets.md` | all declared `pre-tag` commands exit 0 on a clean tree | rows run green | A-2.9 | T-34 | PENDING | -| T-36 | `core/pysrc/releasehash.py`, `tools/sync-core.py` generated set | `python tools/sync-core.py --check` passes with it enrolled; `SUITE -k test_pre_tag_hash` — changed hash forces re-confirmation | hash re-confirm | A-2.10 | T-35 | PENDING | -| T-37 | `core/pysrc/_releaselib.py` | `-k manifest_declared` — equality asserted, mismatch BLOCKs | manifest assert | A-3.1 | T-36 | PENDING | -| T-38 | `core/pysrc/_releaselib.py` | `-k manifest_absent` — tag is version source, no assertion | optional manifest | A-3.2 | T-37 | PENDING | -| T-39 | `core/pysrc/_releaselib.py` | `-k rebuild_artifacts` — stale bundle blocks, cause named | rebuild gate | A-3.3 | T-38 | PENDING | -| T-40 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k payload_exclude` — ca-pi `tools/` excluded | payload exclusions | A-3.4 | T-39 | PENDING | -| T-41 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k provenance_optional` — absent → skipped and reported | optional provenance | A-3.5 | T-40 | PENDING | - -**The skill rewrite — the campaign's central deliverable.** Absent from the first plan draft; a review -found the bijection passed because A-6.1 mapped to the guard *script* rather than the cleanup it -enforces. `SKILL.md` here means `core/surface/skills/release/SKILL.md` (the source; three payloads -generate from it). - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-41a | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k skill_uses_loader` — Targets table replaced by `load_targets`; no hardcoded row survives | table → loader | A-6.0 | T-41 | PENDING | -| T-41b | `core/surface/skills/release/SKILL.md` | `python .github/scripts/check_skill_portability.py` — no `.github/scripts/` invocation remains; helpers resolve under `${CLAUDE_PLUGIN_ROOT}` | helper repointing | A-6.0 | T-41a | PENDING | -| T-41c | `core/surface/skills/release/SKILL.md` | `-k skill_provenance_field` — Phase 3 step 5 reads the row field; absent → documented skip | provenance step | A-6.0, A-3.5 | T-41b | PENDING | -| T-41d | `core/surface/skills/release/SKILL.md` | `-k skill_conditional_prose` — hosted-lane and immutability sections conditional on repo capability | prose conditionals | A-6.0 | T-41c | PENDING | -| T-41e | — (review only) | adversarial Opus agent reviews the rewritten skill; BLOCK-level findings fixed and re-reviewed before proceeding | mid-sprint review | A-6.0 | T-41d | PENDING | -| T-42 | `core/surface/skills/release/SKILL.md` | `-k interpreter_fallback` — uses the shipped `python3 "

" … \|\| python "

" …` pattern (`taskwrite.py:11`) | interpreter fallback | A-3.6 | T-41e | PENDING | -| T-43 | `.github/scripts/payload_version_gate.py` | `python .github/scripts/test_payload_version_gate.py -k no_prefix_literal` | CI reads declared source | A-4.1 | T-42 | PENDING | -| T-44a | `.github/scripts/_releaselib.py` | `python .github/scripts/test_release_lib.py -k select_target_name_keyed` — `name=value` argv pairs; unknown name fails closed | shim CLI shape | A-4.2 | T-43 | PENDING | -| T-44b | `.github/workflows/release.yml` | `python .github/scripts/test_release_workflow.py -k name_keyed` — inputs plumbed by name, order-independent | workflow plumbing | A-4.2 | T-44a | PENDING | -| T-45 | `.github/scripts/test_release_workflow.py` | `-k name_agreement` fails when declared set and workflow inputs disagree | contract test | A-4.3 | T-44b | PENDING | -| T-46 | `.github/scripts/_releaselib.py` | `python .github/scripts/test_payload_version_gate.py`; `python .github/scripts/test_release_workflow.py`; `python .github/scripts/test_release_lib.py` all green; A-1.9's `test_releaselib_shim_exports_constants` deleted in this commit | shim data removed | A-4.4 | T-45 | PENDING | - -### Step 3 — A slice 5: onboarding and back-fill - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-47 | `core/surface/skills/decompose/SKILL.md` | `python .github/scripts/test_board_sync.py -k decompose_intent_only` | intent-only elicitation | A-5.1 | T-46 | PENDING | -| T-48 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_release_lib.py -k context_creation_writes_loadable` — the written file parses via `load_targets` | full elicitation | A-5.2 | T-47 | PENDING | -| T-49 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_requires_confirmation` — no write without confirm | back-fill gate | A-5.3 | T-48 | PENDING | -| T-50 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_persists` — second run reads, does not re-detect | back-fill persist | A-5.4 | T-49 | PENDING | -| T-51 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k first_release_baseline` — baseline derived from `git log --diff-filter=A -- .codearbiter/CONTEXT.md`, with a user override offered in the prompt | adoption baseline | A-5.5 | T-50 | PENDING | -| T-52 | `.codearbiter/.provenance/release-targets.json` | `python .github/scripts/test_provenancelib.py -k release_targets_triggers` | drift triggers | A-5.6 | T-51 | PENDING | - -### Step 4 — B3: the two conversions - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-53 | `core/pysrc/taskwrite.py`, `core/pysrc/_taskboardlib.py` | `python .github/scripts/test_taskwriter.py -k add_rationale` — sub-bullet round-trips; `python tools/sync-core.py --check` passes | helper extension | B-17 | T-52 | PENDING | -| T-54 | `core/surface/skills/debug/SKILL.md` | `python .github/scripts/test_board_sync.py -k debug_uses_helper` — no direct append remains | debug converted | B-16 | T-53 | PENDING | -| T-55 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_board_sync.py -k context_creation_board_route` — seeds via a repeated `taskwrite add` loop; **no file-absent exemption predicate exists** | scaffold route | B-18 | T-54 | PENDING | -| T-56 | `.github/scripts/test_board_sync.py` | `-k done_flip_retained` — flip classifies RETAINED with enrolment **simulated in a fixture**; live post-enrolment coverage is T-67 | ADR-0008 composition | B-19 | T-55 | PENDING | - -### Step 5 — B4: archive verb, done-tasks, sweep - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-57 | `core/pysrc/_taskboardlib.py` | `test_taskwriter.py -k archive_transform` — pure text→text move | archive transform | B-20 | T-56 | PENDING | -| T-58 | `core/pysrc/taskwrite.py` | `python .github/scripts/test_taskwriter.py -k archive_verb` — appends then removes, per item; **creates `done-tasks.md` with the canonical header when absent** | archive verb | B-20, B-23 | T-57 | PENDING | -| T-59 | `.github/scripts/test_taskwriter.py` | `-k archive_rerun` — dotted-ID and exact-text dedup, no duplicate | rerun safety | B-21 | T-58 | PENDING | -| T-60 | `.github/scripts/test_taskwriter.py` | `-k archive_interrupted` — kill between phases leaves no dup and no loss | interruption safety | B-22 | T-59 | PENDING | -| T-61 | `core/pysrc/init-codearbiter.py` | `python .github/scripts/test_taskboardlib.py -k done_tasks_shape` — greenfield scaffold path; `python tools/sync-core.py --check` passes | done-tasks scaffolded | B-23 | T-60 | PENDING | -| T-62 | `core/pysrc/_taskboardlib.py` | `-k archive_cutoff` — named constant, injected date | cutoff constant | B-25 | T-61 | PENDING | -| T-63 | `core/pysrc/_taskboardlib.py` | `-k archive_undated` — undated `[x]` items appear in their own section, **excluded from cutoff math**, archivable only per-item | undated rule | B-26 | T-62 | PENDING | -| T-64 | `core/surface/commands/standup.md` | `python .github/scripts/test_ux_conversion.py -k standup_sweep` — per-item confirmation | standup owns sweep | B-24 | T-63 | PENDING | -| T-65 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_done_tasks_registered` — append-only, archive verb admitted | consumer 3 enrolled | B-15 | T-64 | PENDING | - -### Step 6 — B2: open-tasks enrolment (lands last, per sequencing) - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-66 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_open_tasks_registered` — helper-only, no marker path | consumer 2 enrolled | B-14 | T-65 | PENDING | -| T-67 | full suite | `SUITE` green; `python .github/scripts/test_taskwriter.py`; `test_board_sync.py` | enrolment regression | B-05, B-12, B-19 | T-66 | PENDING | - -### Step 7 — A slice 6: surfaces - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-68a | `.github/scripts/check_skill_portability.py` | `python .github/scripts/test_skill_portability.py -k matching_rule` — reference-form rule stated in the docstring; flags an executed `.github/scripts/` path, does **not** flag a scan-target list entry | matching rule | A-6.1 | T-67 | PENDING | -| T-68b | `.github/scripts/check_skill_portability.py` | guard exits 1 against `core/surface/skills/**` at `469c2fb`, exits 0 after T-41a–d, T-69 and T-70 | guard wiring | A-6.1 | T-68a | PENDING | -| T-69 | `core/surface/skills/subagent-driven-development/SKILL.md` | guard passes; reference reads `${CLAUDE_PLUGIN_ROOT}/tools/farm.js` | farm.js reference | A-6.2 | T-68b | PENDING | -| T-70 | `core/surface/skills/decision-lifecycle/SKILL.md` | guard passes; line is a conditional CI reference | ADR-identity reference | A-6.3 | T-69 | PENDING | -| T-71 | `core/surface/commands/release.md` | `python .github/scripts/check-plugin-refs.py`; documents `[target]` only — `--auto`, `--dry-run` and `` deleted (a real dry-run is tracked as #565); phase numbers match the skill | command surface | A-6.4 | T-70 | PENDING | -| T-72 | `site/src/content/docs/guides/releasing-a-version.md` | `npm --prefix site test` green | docs guide | A-6.5 | T-71 | PENDING | - -### Step 8 — Completion proof (the sprint is not done without this) - -Per the maintainer's completion bar: proven to work **and** to port. Verifying against this repo's -hand-built `.codearbiter/` state is the documented way consumer-facing bugs stay hidden, so the -consumer proof runs in a scratch repo with no file from this repository present. - -| id | path(s) | verification | maps-to | covers | depends | status | -|---|---|---|---|---|---|---| -| T-73 | `.github/scripts/test_consumer_smoke.py` | scratch repo built: one `package.json`, one `CHANGELOG.md`, tag `v1.2.3`, codeArbiter installed, **zero files from this repo** | consumer fixture | A-6.6 | T-72 | PENDING | -| T-74 | `.github/scripts/test_consumer_smoke.py` | `-k backfill_detects` — no declared file → detected shape presented, refuses to proceed unconfirmed | consumer back-fill | A-6.6 | T-73 | PENDING | -| T-75 | `.github/scripts/test_consumer_smoke.py` | `-k consumer_end_to_end` — target resolution, window derivation, bump classification and changelog rolling all succeed with no non-payload path touched | portability proof | A-6.6 | T-74 | PENDING | -| T-76 | `.github/scripts/test_release_trace.py` | `-k this_repo_still_releases` — `/ca:release ca` composes a tag on a scratch branch at the version the pre-change lane would derive; tag discarded, never pushed | this repo still releases | A-6.7 | T-75 | PENDING | - -## Pre-run dispositions (maintainer-answered 2026-07-31) - -Encoded here so a subagent does not re-surface them. Full SMARTS in `sprint-log.md`. - -- **T-06 – T-08 — delegate with a tripwire.** Proceed when `security-reviewer` PASSes and B-07…B-12 - are green; halt only on a finding. This was a risk-appetite call, not an analysis result: Reliable - and Securable favored an unconditional halt. -- **T-16 / T-32 — content pre-approved.** ADR-**0024** (verified next-free) records the - executable-input boundary, the ≤1024 cap, that the marker is audit friction rather than - authorization, and names the ADR-0010 shell-indirection residual per flank. The - `security-controls.md` row mirrors ADR-0002's. T-32 lands without a stop; **T-16 still halts, for - attribution only**. -- **T-41e — adversarial Opus review**, not a maintainer stop. Maintainer reviews the skill text at PR - stage. -- **Identifiers** — hook ID **H-22** (H-21 is taken); ADR **0024**; marker `release-targets-authoring`, - with `-authoring` as the pattern for future consumers. -- **T-13 / T-65 — `append-only` blocks all tool writes.** Flank-identical to `helper-only`; the - distinction lives in the helper's verb constraint. No tail-anchored-Edit admission. -- **T-55 — helper loop, no exemption predicate.** A file-absent exemption would let delete-then-Write - launder arbitrary content through "the file is absent". - -**LOW — a known dead window.** Between T-33 (rows enrolled `marker-gated`) and T-49/T-50 (the minter -prose lands), no lane can legally edit `release-targets.md`. A correction in that window needs a -hand-armed marker or `/ca:override`. Expected, not a defect — do not treat the block as a failure. - -## Coverage proof - -**Every AC → at least one task.** A-1.1→T-17, A-1.2→T-18, A-1.3→T-19, A-1.4→T-20, A-1.5→T-21, -A-1.6→T-22, A-1.7→T-23, A-1.8→T-24, A-1.9→T-25/T-46, A-1.10→T-26, A-1.11→T-27a/b/c/d, A-2.1→T-28, -A-2.2→T-29, A-2.3→T-30, A-2.4→T-31, A-2.5→T-32, A-2.6→T-33, A-2.7→T-33, A-2.8→T-34, A-2.9→T-35, -A-2.10→T-36, A-3.1→T-37, A-3.2→T-38, A-3.3→T-39, A-3.4→T-40, A-3.5→T-41/T-41c, A-3.6→T-42, -A-4.1→T-43, A-4.2→T-44a/T-44b, A-4.3→T-45, A-4.4→T-46, A-5.1→T-47, A-5.2→T-48, A-5.3→T-49, -A-5.4→T-50, A-5.5→T-51, A-5.6→T-52, **A-6.0→T-41a/T-41b/T-41c/T-41d/T-41e**, A-6.1→T-68a/T-68b, -A-6.2→T-69, A-6.3→T-70, A-6.4→T-71, A-6.5→T-72, **A-6.6→T-73/T-74/T-75, A-6.7→T-76**. -B-01→T-01/02/03/15/T-05a/T-05b, B-02→T-04/T-06, B-03→T-07, B-04→T-08/T-08a, B-05→T-06/07/08/T-67, -B-06→T-13, B-07→T-09, B-08→T-10/T-08b, B-09→T-11, B-10→T-05, B-11→T-14, B-12→T-12/T-67, B-13→T-33, B-14→T-66, -B-15→T-65, B-16→T-54, B-17→T-53, B-18→T-55, B-19→T-56/T-67, B-20→T-57/T-58, B-21→T-59, B-22→T-60, -B-23→T-58/T-61, B-24→T-64, B-25→T-62, B-26→T-63, B-27→T-16. - -**Every task → at least one AC.** Verified across all 90 tasks; no task covers nothing. - -Bijective coverage proven: **72 criteria, 90 tasks**, no uncovered criterion and no orphan task. -(90 rather than 86 after the proxy rulings added T-05a, T-05b, T-08a, T-08b — the classifier -extension, the legacy-overlap guard, the marker-minting fence, and the lexical-residual pin.) - -*Rev 2/3 note — two holes were in the ledger, not the task set.* The first draft claimed bijection -over 69 criteria and 72 tasks. The claim was formally true and hollow both times: A-6.1 mapped to the -portability guard *script* while nothing rewrote the skill it polices (closed by A-6.0 + T-41x), and -#563's consumer-portability acceptance existed only as a prose checkbox, so no criterion and -therefore no task covered the thing the campaign is *for* (closed by A-6.6/A-6.7 + T-73–T-76). A -coverage proof over a criteria set with a hole in it proves the hole is consistent, nothing more. - -## Dependency order - -Strictly linear as written, with no cycle: -B1 (T-01–16) → A 1–4 (T-17–T-46, including the T-41x skill rewrite) → A 5 (T-47–52) → -B3 (T-53–56) → B4 (T-57–65) → B2 enrolment (T-66–67) → A 6 (T-68a–72). - -Ordering constraints that are not merely sequential: - -- **T-46 must not land before T-43–T-45**, or `payload_version_gate.py` breaks on every PR. T-25's - transitional test is deleted in T-46's own commit. -- **T-41a–d must land before T-68b**, since the guard cannot go green while the release skill still - carries its non-payload references. T-68b's verification names them explicitly. -- **T-58 must land before T-65.** The archive verb creates `done-tasks.md` when absent; once the file - is enrolled `append-only`, no tool write can create it. Every already-initialized repo — including - this one — never re-runs `init-codearbiter.py`, so T-61's scaffold path alone would leave the file - missing and the first archive failing. - -## Hard gates on the critical path - -After the pre-run dispositions, **two** stops remain rather than four: - -- **T-16** — ADR-0024 attribution. Content pre-approved; the halt is the signing act only. -- **Landing** — merge to the default branch; `/ca:sprint` auto-selects open-PR and never merges. - -Downgraded, with the reason recorded: - -- **T-06 – T-08** — now conditional. Halts only if `security-reviewer` reports a finding or any of - B-07…B-12 is red. -- **T-32** — no longer a stop; the boundary row text is pre-approved. - -Conditional stops that are not scheduled but may fire on genuinely new evidence: - -- **T-27c** — if the transcribed old-lane script disagrees with the live repo, that is new evidence - about the pre-change lane, not a fixup. STOP and investigate. -- **T-35** — a stale badge is not a stop (DECISION-0034 pre-decides the reconcile-and-rerun loop), but - drift revealing the catalog itself is wrong is new information. - -## Out of scope - -- `[NEEDS-TRIAGE]` D-6: whether `.github/published-tags.json` relocates to `.codearbiter/`. Tracked - in `open-questions.md`; not planned here. +# Plan — portable release + protected-state machinery + +**Spec:** `.codearbiter/specs/portable-release-and-protected-state.md` +**Companion spec:** `.codearbiter/specs/release-portable-fixture.md` (rev 4) +**Date:** 2026-07-31 +**Status column is the resume ledger** — `subagent-driven-development` flips a task to `ACCEPTED` on +acceptance; an interrupted run re-enters at the first non-`ACCEPTED` task. + +> **Verification note.** Every `unittest discover` verification MUST be run with `NO_COLOR` unset. +> This harness exports `NO_COLOR=1`, which makes `statusline.py` strip SGR and fails 7 palette tests +> against a healthy tree. Prefix with `Remove-Item Env:\NO_COLOR -ErrorAction SilentlyContinue;` in +> the same shell call. + +## AC ledger + +### Workstream A — release portability + +The companion spec is **authoritative**; criteria are cited by ID with a short label rather than +duplicated, so the two documents cannot drift. + +| AC | label | +|---|---| +| A-1.1 | `core/pysrc/_releaselib.py` exists; `sync-core --check` passes | +| A-1.2 | mechanism carries no repo-namespace literal (denylist) | +| A-1.3 | repo defaults become required params (`classify_merge_readiness`, `last_tag_select`, `select_release_target`) | +| A-1.4 | `load_targets(path)` returns the full row schema | +| A-1.5 | absent block → declared error | +| A-1.6 | each parser-contract violation → its own declared error (8 cases) | +| A-1.7 | empty block → declared error | +| A-1.8 | series isolation against loaded data | +| A-1.9 | *(transitional)* shim re-exports mechanism, still exposes `RELEASE_TAG_PREFIXES` | +| A-1.10 | this repo's four rows load; target+prefix equal pre-change constants | +| A-1.11 | resolution trace reproduces a recorded pre-change run for `ca` and `ca-pi` | +| A-2.1 | pre-tag commands execute in declared order | +| A-2.2 | non-zero exit blocks | +| A-2.3 | dirty tree blocks; assertion precedes `rebuild` | +| A-2.4 | >1024-char `pre-tag` entry rejected | +| A-2.5 | `security-controls.md` boundary-crossings entry | +| A-2.6 | protected class admits `release-targets.md` writes only under marker | +| A-2.7 | four-case flank test | +| A-2.8 | `check_command_catalog.py` exists and is non-mutating | +| A-2.9 | this repo's declared rows run green on a reconciled tree | +| A-2.10 | `pre-tag` content-hash change forces re-confirmation | +| A-3.1 | declared manifest → assert equality, BLOCK on mismatch | +| A-3.2 | no manifest → tag is version source | +| A-3.3 | `rebuild` runs; artifacts asserted clean; nondeterministic bundler named | +| A-3.4 | `payload-exclude` honored (ca-pi `tools/`) | +| A-3.5 | `provenance-manifest` optional; absent → skip + report | +| A-3.6 | interpreter fallback where `python3` absent | +| A-4.1 | `payload_version_gate.py` derives prefixes from declared file | +| A-4.2 | target selection name-keyed | +| A-4.3 | workflow-contract test on name agreement | +| A-4.4 | constants removed from shim; six sites + gate still pass; A-1.9 test retired | +| A-5.1 | `decompose` elicits intent only | +| A-5.2 | `context-creation` writes a file `load_targets` accepts | +| A-5.3 | back-fill presents and requires confirmation | +| A-5.4 | back-fill persists; second run reads | +| A-5.5 | first-release changelog baseline instead of per-commit BLOCK | +| A-5.6 | provenance triggers are the rows' referenced paths | +| A-6.1 | reference-form guard over `core/surface/skills/**` | +| A-6.2 | `subagent-driven-development` farm.js reference resolves | +| A-6.3 | `decision-lifecycle` reworded to conditional CI reference | +| A-6.4 | `commands/release.md` matches the skill | +| A-6.5 | docs-site guide distinguishes general lane from this repo | + +### Workstream B — protected-state machinery + +| AC | criterion | +|---|---| +| B-01 | The registry carries a per-entry policy enum `{marker-gated, helper-only, append-only}`, present from slice 1 | +| B-02 | `marker-gated`: a Write is admitted only under a fresh authoring marker | +| B-03 | `marker-gated`: the Edit flank blocks via `classify_protected` per-class dispatch | +| B-04 | `marker-gated`: the shell flank blocks via a redirect + write-verb regex pair | +| B-05 | `helper-only`: Write, Edit, and shell naming the file are hard-blocked with **no** marker path | +| B-06 | `append-only`: mutation is admitted only via the helper's append verb | +| B-07 | `git add open-tasks.md` passes the shell flank | +| B-08 | `taskwrite add -- "fix open-tasks.md schema"` passes (filename as argv data) | +| B-09 | `tee open-tasks.md` and `>> open-tasks.md` block | +| B-10 | A stale marker (older than the freshness window) does not admit a write | +| B-11 | The class carries a stable `H-NN` ID cited in code comments and its test | +| B-12 | With enrolment live, a `taskwrite.py` invocation still succeeds (circularity proof) | +| B-13 | `release-targets.md` is registered `marker-gated` | +| B-14 | `open-tasks.md` is registered `helper-only` | +| B-15 | `done-tasks.md` is registered `append-only` | +| B-16 | `debug/SKILL.md:80` writes via `{{CMD:task}} add` rather than a direct append | +| B-17 | `taskwrite add` supports the rationale sub-bullet `debug` requires | +| B-18 | `context-creation`'s board population routes through the helper or a declared scaffold-time exemption | +| B-19 | A done-flip still classifies RETAINED through `classify_board_diff` after enrolment | +| B-20 | `taskwrite archive ` appends to `done-tasks.md` then removes from `open-tasks.md`, per item | +| B-21 | `archive` is rerun-safe: dedup by dotted ID, exact text for ID-less entries | +| B-22 | Interruption mid-sweep leaves item-level consistency — no duplicate, no loss | +| B-23 | `done-tasks.md` is created with the expected header shape | +| B-24 | `/ca:standup` offers the sweep under per-item confirmation | +| B-25 | Cutoff is a named constant, default done > 14 days, tested with an injected date | +| B-26 | An undated `[x]` archives only under explicit per-item confirmation | +| B-27 | An ADR records the executable-input boundary and names the ADR-0010 shell-indirection residual | + +## Tasks + +Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, shared kernel in +`core/pysrc/`, hook tests in `plugins/ca/hooks/tests/`, standalone gates in `.github/scripts/`. +`SUITE` = `Remove-Item Env:\NO_COLOR -EA SilentlyContinue; python -m unittest discover -s plugins/ca/hooks/tests -p "test_*.py"`. + +### Step 1 — B1: the class and registry (MVP slice begins) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-01 | `core/pysrc/_protectedstatelib.py` | `python -m py_compile` passes; module imports with zero side effects | registry module exists | B-01 | — | ACCEPTED | +| T-02 | `core/pysrc/_protectedstatelib.py`, `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_policy_enum` — all three policies present, unknown policy raises | policy enum | B-01 | T-01 | ACCEPTED | +| T-03 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_registry_lookup` — registered path returns its policy, unregistered returns None | registry lookup | B-01 | T-02 | ACCEPTED | +| T-04 | `core/pysrc/_protectedstatelib.py`, `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_gated_write` — fresh marker admits, absent marker blocks | marker-gated Write | B-02 | T-03 | ACCEPTED | +| T-05 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_marker_stale` — marker older than the window blocks | marker freshness | B-10 | T-04 | ACCEPTED | +**Flank wiring design — proxy-ruled 2026-07-31, do not re-derive.** Full reasoning in `sprint-log.md`. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-05a | `core/pysrc/_protectedlib.py` | `python .github/scripts/test_hooklib.py -k ClassifyProtectedStateTest` — `classify_protected` returns `"state"` for a registered path; return contract stays a set of strings so its four consumers see no change | classifier extension | B-01 | T-05 | ACCEPTED | +| T-05b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_no_legacy_overlap` — **no** registered path classifies into any legacy class; overlap fails loudly as a configuration error rather than resolving by precedence | overlap guard | B-01 | T-05a | ACCEPTED | +| T-06 | `core/pysrc/pre-write.py` | `SUITE -p "test_pre_write.py" -k TestH22ProtectedState` — one generic `"state"` branch resolves the entry's policy from the registry and applies it; no second lookup | pre-write flank | B-02, B-05 | T-05b | ACCEPTED | +| T-07 | `core/pysrc/pre-edit.py` | `SUITE -p "test_pre_edit.py" -k TestH22ProtectedState` — same generic branch; `helper-only` blocks **unconditionally**, no marker path | pre-edit flank | B-03, B-05 | T-06 | ACCEPTED | +| T-08 | `core/pysrc/_bashguardlib.py` | `SUITE -k TestStateShell` — `_state_write_res(basename) -> (redirect_re, write_re)` compiled once at import from the **code-constant** registry, per entry rather than one alternation. `TestStateShellWiring` must drive `run_guards()` itself: every other shell test calls `_check_h22_state` directly and would pass with the wiring deleted | shell flank | B-04, B-05 | T-07 | ACCEPTED | +| T-08a | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_marker_touch_allowed` — `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; `GATE_MARKER_NAMES` gains a comment stating it enumerates **block-to-allow** markers while friction markers stay touchable by design | minting fence | B-04 | T-08 | ACCEPTED | +| T-08b | `plugins/ca/hooks/tests/test_protectedstatelib.py` | `SUITE -k test_verb_in_description_residual` — documents that `taskwrite add -- "remember to tee open-tasks.md"` false-blocks; pins the **passing** B-08 form and records the residual rather than chasing it with smarter parsing | lexical residual | B-08 | T-08a | ACCEPTED | + +**Rulings encoded above, with the reasoning that produced them:** + +- **The registry is code constants, never disk-loaded.** A disk registry would let a consumer repo + un-protect `open-tasks.md` by editing a file. Zero-side-effects-at-import prohibits file I/O and + git, not regex compilation from module constants (`_scopelib.py:109-117` precedent). +- **Dispatch extends `classify_protected` rather than sitting beside it.** `_protectedlib.py:13-19` + records #528/#529, where independent class checks deadlocked because set membership was + uncoordinated; and #162 symlink laundering is closed *inside* the classifier + (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms). A parallel + lookup ships without symlink resolution, and a symlink alias writes through the guard on day one. +- **`helper-only` is unconditional, with merge conflicts as a named residual.** A conflict in + `open-tasks.md` itself has no `taskwrite` verb, so resolution routes through logged + `/ca:override`. The ADR carries a reopen condition: if `gate-events.log` shows board-conflict + overrides recurring, build a `taskwrite resolve` verb — never a guard exception. A + conflict-marker content predicate was rejected as converting file content into an authorization + signal. +- **`GATE_MARKER_NAMES` is not widened.** It enumerates markers that convert a BLOCK into an ALLOW; + an authoring marker fakes nothing and is self-mintable by design (ADR-0010). The risk runs + opposite to intuition — a future generic "hardening" over every registered marker would brick + every minting lane while stopping no non-cooperative agent. T-08a is the fence against that. +| T-09 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_git_add_passes` — `git add open-tasks.md` passes. Load-bearing: `commit-gate` Phase 7 runs exactly that on every retained board flip, so a git verb in the list makes commit-gate block itself | git-verb non-regression | B-07 | T-08 | ACCEPTED | +| T-10 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_filename_as_helper_argv_data_passes` — helper call with the filename in its description passes | argv-data non-regression | B-08 | T-08 | ACCEPTED | +| T-11 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_tee_blocks_unconditionally` and `-k test_append_redirect_blocks_unconditionally` | shell-write blocking | B-09 | T-08 | ACCEPTED | +| T-12 | `.../tests/test_protectedstatelib.py` | `SUITE -k test_taskwrite_invocation_passes_with_enrolment_live` — the real `core/surface/commands/task.md` invocation shape. **Not mutation-killable by construction** — the command names no registered basename, which is the property being proved; assert it against the generated invocation rather than a hand-copy so it notices drift | circularity proof | B-12 | T-08 | ACCEPTED | +| T-13 | `.../tests/test_protectedstatelib.py` | `SUITE -k TestStateShellAppendOnly` — non-append mutation blocks, append verb admitted | append-only policy | B-06 | T-08 | ACCEPTED | +| T-14 | `core/pysrc/pre-write.py`, `plugins/ca/hooks/*.py` | `python .github/scripts/check-plugin-refs.py` passes; `H-NN` cited in code and test | stable hook ID | B-11 | T-08 | ACCEPTED | +| T-15 | `core/pysrc/_protectedstatelib.py` → generated | `python tools/sync-core.py --check` passes | byte-identity | B-01 | T-14 | ACCEPTED | +| T-16 | `.codearbiter/decisions/00NN-*.md` | ADR file exists, dated, user-attributed, names the ADR-0010 residual | ADR authored | B-27 | T-15 | ACCEPTED | + +**HARD GATE at T-16** — `/ca:adr` requires user attribution. Halts and surfaces. + +### Step 2 — A slices 1–4 (MVP slice continues through T-33) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-17 | `core/pysrc/_releaselib.py` | `python tools/sync-core.py --check` passes | mechanism ships | A-1.1 | T-15 | ACCEPTED | +| T-18 | `core/pysrc/_releaselib.py`, `.github/scripts/test_release_lib.py` | `python .github/scripts/test_release_lib.py -k denylist` — no repo literal | data-free mechanism | A-1.2 | T-17 | ACCEPTED | +| T-19 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k required_params` — three functions reject missing args | required params | A-1.3 | T-18 | ACCEPTED | +| T-20 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k load_targets` — full row schema from a fixture | loader | A-1.4 | T-19 | ACCEPTED | +| T-21 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k absent_block` raises the declared error | absent-block error | A-1.5 | T-20 | ACCEPTED | +| T-22 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k parser_contract` — 8 violations, 8 distinguishable errors | parser contract | A-1.6 | T-21 | ACCEPTED | +| T-23 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k empty_block` raises the declared error | empty-block error | A-1.7 | T-22 | ACCEPTED | +| T-24 | `.github/scripts/test_release_lib.py` | `-k series_isolation` — `v` and `ca-pi-v` resolve independently | series isolation | A-1.8 | T-23 | ACCEPTED | +| T-25 | `.github/scripts/_releaselib.py` | `python .github/scripts/payload_version_gate.py --plugin plugins/ca --base origin/main` exits 0 (bare invocation exits 2 — args are required) | transitional shim | A-1.9 | T-24 | PENDING | +| T-26 | `.codearbiter/release-targets.md` | `python .github/scripts/test_release_lib.py -k this_repo_rows` — 4 rows load, prefixes match constants, **and every one of the four declares `provenance-manifest`** | repo rows declared | A-1.10 | T-25 | PENDING | +| T-27a | `.github/scripts/fixtures/release-trace/` | `python .github/scripts/test_release_trace.py -k fixture_shape` — frozen tag list, manifests, commit graph, 4 rows | trace fixture | A-1.11 | T-26 | PENDING | +| T-27b | `.github/scripts/test_release_trace.py` | `-k old_lane_loads` — helpers pinned via `git show :.github/scripts/_releaselib.py` | pinned old lane | A-1.11 | T-27a | PENDING | +| T-27c | `.github/scripts/test_release_trace.py` | `-k old_lane_live` — the transcribed old lane resolves `ca`'s real last tag against the live repo; **divergence is a STOP, not a fixup** | old-lane validation | A-1.11 | T-27b | PENDING | +| T-27d | `.github/scripts/test_release_trace.py` | `-k trace_matches` — new lane reproduces the recorded variable dict for `ca` **and** `ca-pi` | trace assertion | A-1.11 | T-27c | PENDING | +| T-28 | `core/surface/skills/release/SKILL.md` | `test_release_lib.py -k pre_tag_order` — declared order preserved | pre-tag order | A-2.1 | T-27 | PENDING | +| T-29 | `core/pysrc/_releaselib.py` | `-k pre_tag_exit` — non-zero exit blocks | pre-tag exit | A-2.2 | T-28 | PENDING | +| T-30 | `core/pysrc/_releaselib.py` | `-k pre_tag_dirty` — dirty tree blocks, assertion precedes rebuild | clean-tree gate | A-2.3 | T-29 | PENDING | +| T-31 | `core/pysrc/_releaselib.py` | `-k pre_tag_cap` — >1024 chars rejected | length cap | A-2.4 | T-30 | PENDING | +| T-32 | `.codearbiter/security-controls.md` | boundary-crossings entry present; `test_release_lib.py -k boundary_entry` | boundary declared | A-2.5 | T-31 | PENDING | +| T-33 | `core/pysrc/_protectedstatelib.py`, `.codearbiter/release-targets.md` | `SUITE -k test_release_targets_registered` — marker-gated, 4-case flank test passes | consumer 1 enrolled | A-2.6, A-2.7, B-13 | T-32 | PENDING | + +**HARD GATE at T-32** — `security-controls.md` is a trust-boundary change. + +**— END MVP SLICE —** At T-33 the registry exists with a live consumer, the mechanism ships, this +repo's rows load, and the release lane is proven behavior-identical. Shippable on its own. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-34 | `.github/scripts/check_command_catalog.py` | script exits 0 on a reconciled tree, 1 on drift, mutates nothing | catalog check | A-2.8 | T-33 | PENDING | +| T-35 | `.codearbiter/release-targets.md` | all declared `pre-tag` commands exit 0 on a clean tree | rows run green | A-2.9 | T-34 | PENDING | +| T-36 | `core/pysrc/releasehash.py`, `tools/sync-core.py` generated set | `python tools/sync-core.py --check` passes with it enrolled; `SUITE -k test_pre_tag_hash` — changed hash forces re-confirmation | hash re-confirm | A-2.10 | T-35 | PENDING | +| T-37 | `core/pysrc/_releaselib.py` | `-k manifest_declared` — equality asserted, mismatch BLOCKs | manifest assert | A-3.1 | T-36 | PENDING | +| T-38 | `core/pysrc/_releaselib.py` | `-k manifest_absent` — tag is version source, no assertion | optional manifest | A-3.2 | T-37 | PENDING | +| T-39 | `core/pysrc/_releaselib.py` | `-k rebuild_artifacts` — stale bundle blocks, cause named | rebuild gate | A-3.3 | T-38 | PENDING | +| T-40 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k payload_exclude` — ca-pi `tools/` excluded | payload exclusions | A-3.4 | T-39 | PENDING | +| T-41 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k provenance_optional` — absent → skipped and reported | optional provenance | A-3.5 | T-40 | PENDING | + +**The skill rewrite — the campaign's central deliverable.** Absent from the first plan draft; a review +found the bijection passed because A-6.1 mapped to the guard *script* rather than the cleanup it +enforces. `SKILL.md` here means `core/surface/skills/release/SKILL.md` (the source; three payloads +generate from it). + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-41a | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k skill_uses_loader` — Targets table replaced by `load_targets`; no hardcoded row survives | table → loader | A-6.0 | T-41 | PENDING | +| T-41b | `core/surface/skills/release/SKILL.md` | `python .github/scripts/check_skill_portability.py` — no `.github/scripts/` invocation remains; helpers resolve under `${CLAUDE_PLUGIN_ROOT}` | helper repointing | A-6.0 | T-41a | PENDING | +| T-41c | `core/surface/skills/release/SKILL.md` | `-k skill_provenance_field` — Phase 3 step 5 reads the row field; absent → documented skip | provenance step | A-6.0, A-3.5 | T-41b | PENDING | +| T-41d | `core/surface/skills/release/SKILL.md` | `-k skill_conditional_prose` — hosted-lane and immutability sections conditional on repo capability | prose conditionals | A-6.0 | T-41c | PENDING | +| T-41e | — (review only) | adversarial Opus agent reviews the rewritten skill; BLOCK-level findings fixed and re-reviewed before proceeding | mid-sprint review | A-6.0 | T-41d | PENDING | +| T-42 | `core/surface/skills/release/SKILL.md` | `-k interpreter_fallback` — uses the shipped `python3 "

" … \|\| python "

" …` pattern (`taskwrite.py:11`) | interpreter fallback | A-3.6 | T-41e | PENDING | +| T-43 | `.github/scripts/payload_version_gate.py` | `python .github/scripts/test_payload_version_gate.py -k no_prefix_literal` | CI reads declared source | A-4.1 | T-42 | PENDING | +| T-44a | `.github/scripts/_releaselib.py` | `python .github/scripts/test_release_lib.py -k select_target_name_keyed` — `name=value` argv pairs; unknown name fails closed | shim CLI shape | A-4.2 | T-43 | PENDING | +| T-44b | `.github/workflows/release.yml` | `python .github/scripts/test_release_workflow.py -k name_keyed` — inputs plumbed by name, order-independent | workflow plumbing | A-4.2 | T-44a | PENDING | +| T-45 | `.github/scripts/test_release_workflow.py` | `-k name_agreement` fails when declared set and workflow inputs disagree | contract test | A-4.3 | T-44b | PENDING | +| T-46 | `.github/scripts/_releaselib.py` | `python .github/scripts/test_payload_version_gate.py`; `python .github/scripts/test_release_workflow.py`; `python .github/scripts/test_release_lib.py` all green; A-1.9's `test_releaselib_shim_exports_constants` deleted in this commit | shim data removed | A-4.4 | T-45 | PENDING | + +### Step 3 — A slice 5: onboarding and back-fill + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-47 | `core/surface/skills/decompose/SKILL.md` | `python .github/scripts/test_board_sync.py -k decompose_intent_only` | intent-only elicitation | A-5.1 | T-46 | PENDING | +| T-48 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_release_lib.py -k context_creation_writes_loadable` — the written file parses via `load_targets` | full elicitation | A-5.2 | T-47 | PENDING | +| T-49 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_requires_confirmation` — no write without confirm | back-fill gate | A-5.3 | T-48 | PENDING | +| T-50 | `core/surface/skills/release/SKILL.md` | `python .github/scripts/test_release_lib.py -k backfill_persists` — second run reads, does not re-detect | back-fill persist | A-5.4 | T-49 | PENDING | +| T-51 | `core/pysrc/_releaselib.py` | `python .github/scripts/test_release_lib.py -k first_release_baseline` — baseline derived from `git log --diff-filter=A -- .codearbiter/CONTEXT.md`, with a user override offered in the prompt | adoption baseline | A-5.5 | T-50 | PENDING | +| T-52 | `.codearbiter/.provenance/release-targets.json` | `python .github/scripts/test_provenancelib.py -k release_targets_triggers` | drift triggers | A-5.6 | T-51 | PENDING | + +### Step 4 — B3: the two conversions + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-53 | `core/pysrc/taskwrite.py`, `core/pysrc/_taskboardlib.py` | `python .github/scripts/test_taskwriter.py -k add_rationale` — sub-bullet round-trips; `python tools/sync-core.py --check` passes | helper extension | B-17 | T-52 | PENDING | +| T-54 | `core/surface/skills/debug/SKILL.md` | `python .github/scripts/test_board_sync.py -k debug_uses_helper` — no direct append remains | debug converted | B-16 | T-53 | PENDING | +| T-55 | `core/surface/skills/context-creation/SKILL.md` | `python .github/scripts/test_board_sync.py -k context_creation_board_route` — seeds via a repeated `taskwrite add` loop; **no file-absent exemption predicate exists** | scaffold route | B-18 | T-54 | PENDING | +| T-56 | `.github/scripts/test_board_sync.py` | `-k done_flip_retained` — flip classifies RETAINED with enrolment **simulated in a fixture**; live post-enrolment coverage is T-67 | ADR-0008 composition | B-19 | T-55 | PENDING | + +### Step 5 — B4: archive verb, done-tasks, sweep + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-57 | `core/pysrc/_taskboardlib.py` | `test_taskwriter.py -k archive_transform` — pure text→text move | archive transform | B-20 | T-56 | PENDING | +| T-58 | `core/pysrc/taskwrite.py` | `python .github/scripts/test_taskwriter.py -k archive_verb` — appends then removes, per item; **creates `done-tasks.md` with the canonical header when absent** | archive verb | B-20, B-23 | T-57 | PENDING | +| T-59 | `.github/scripts/test_taskwriter.py` | `-k archive_rerun` — dotted-ID and exact-text dedup, no duplicate | rerun safety | B-21 | T-58 | PENDING | +| T-60 | `.github/scripts/test_taskwriter.py` | `-k archive_interrupted` — kill between phases leaves no dup and no loss | interruption safety | B-22 | T-59 | PENDING | +| T-61 | `core/pysrc/init-codearbiter.py` | `python .github/scripts/test_taskboardlib.py -k done_tasks_shape` — greenfield scaffold path; `python tools/sync-core.py --check` passes | done-tasks scaffolded | B-23 | T-60 | PENDING | +| T-62 | `core/pysrc/_taskboardlib.py` | `-k archive_cutoff` — named constant, injected date | cutoff constant | B-25 | T-61 | PENDING | +| T-63 | `core/pysrc/_taskboardlib.py` | `-k archive_undated` — undated `[x]` items appear in their own section, **excluded from cutoff math**, archivable only per-item | undated rule | B-26 | T-62 | PENDING | +| T-64 | `core/surface/commands/standup.md` | `python .github/scripts/test_ux_conversion.py -k standup_sweep` — per-item confirmation | standup owns sweep | B-24 | T-63 | PENDING | +| T-65 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_done_tasks_registered` — append-only, archive verb admitted | consumer 3 enrolled | B-15 | T-64 | PENDING | + +### Step 6 — B2: open-tasks enrolment (lands last, per sequencing) + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-66 | `core/pysrc/_protectedstatelib.py` | `SUITE -k test_open_tasks_registered` — helper-only, no marker path | consumer 2 enrolled | B-14 | T-65 | PENDING | +| T-67 | full suite | `SUITE` green; `python .github/scripts/test_taskwriter.py`; `test_board_sync.py` | enrolment regression | B-05, B-12, B-19 | T-66 | PENDING | + +### Step 7 — A slice 6: surfaces + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-68a | `.github/scripts/check_skill_portability.py` | `python .github/scripts/test_skill_portability.py -k matching_rule` — reference-form rule stated in the docstring; flags an executed `.github/scripts/` path, does **not** flag a scan-target list entry | matching rule | A-6.1 | T-67 | PENDING | +| T-68b | `.github/scripts/check_skill_portability.py` | guard exits 1 against `core/surface/skills/**` at `469c2fb`, exits 0 after T-41a–d, T-69 and T-70 | guard wiring | A-6.1 | T-68a | PENDING | +| T-69 | `core/surface/skills/subagent-driven-development/SKILL.md` | guard passes; reference reads `${CLAUDE_PLUGIN_ROOT}/tools/farm.js` | farm.js reference | A-6.2 | T-68b | PENDING | +| T-70 | `core/surface/skills/decision-lifecycle/SKILL.md` | guard passes; line is a conditional CI reference | ADR-identity reference | A-6.3 | T-69 | PENDING | +| T-71 | `core/surface/commands/release.md` | `python .github/scripts/check-plugin-refs.py`; documents `[target]` only — `--auto`, `--dry-run` and `` deleted (a real dry-run is tracked as #565); phase numbers match the skill | command surface | A-6.4 | T-70 | PENDING | +| T-72 | `site/src/content/docs/guides/releasing-a-version.md` | `npm --prefix site test` green | docs guide | A-6.5 | T-71 | PENDING | + +### Step 8 — Completion proof (the sprint is not done without this) + +Per the maintainer's completion bar: proven to work **and** to port. Verifying against this repo's +hand-built `.codearbiter/` state is the documented way consumer-facing bugs stay hidden, so the +consumer proof runs in a scratch repo with no file from this repository present. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-73 | `.github/scripts/test_consumer_smoke.py` | scratch repo built: one `package.json`, one `CHANGELOG.md`, tag `v1.2.3`, codeArbiter installed, **zero files from this repo** | consumer fixture | A-6.6 | T-72 | PENDING | +| T-74 | `.github/scripts/test_consumer_smoke.py` | `-k backfill_detects` — no declared file → detected shape presented, refuses to proceed unconfirmed | consumer back-fill | A-6.6 | T-73 | PENDING | +| T-75 | `.github/scripts/test_consumer_smoke.py` | `-k consumer_end_to_end` — target resolution, window derivation, bump classification and changelog rolling all succeed with no non-payload path touched | portability proof | A-6.6 | T-74 | PENDING | +| T-76 | `.github/scripts/test_release_trace.py` | `-k this_repo_still_releases` — `/ca:release ca` composes a tag on a scratch branch at the version the pre-change lane would derive; tag discarded, never pushed | this repo still releases | A-6.7 | T-75 | PENDING | + +## Pre-run dispositions (maintainer-answered 2026-07-31) + +Encoded here so a subagent does not re-surface them. Full SMARTS in `sprint-log.md`. + +- **T-06 – T-08 — delegate with a tripwire.** Proceed when `security-reviewer` PASSes and B-07…B-12 + are green; halt only on a finding. This was a risk-appetite call, not an analysis result: Reliable + and Securable favored an unconditional halt. +- **T-16 / T-32 — content pre-approved.** ADR-**0024** (verified next-free) records the + executable-input boundary, the ≤1024 cap, that the marker is audit friction rather than + authorization, and names the ADR-0010 shell-indirection residual per flank. The + `security-controls.md` row mirrors ADR-0002's. T-32 lands without a stop; **T-16 still halts, for + attribution only**. +- **T-41e — adversarial Opus review**, not a maintainer stop. Maintainer reviews the skill text at PR + stage. +- **Identifiers** — hook ID **H-22** (H-21 is taken); ADR **0024**; marker `release-targets-authoring`, + with `-authoring` as the pattern for future consumers. +- **T-13 / T-65 — `append-only` blocks all tool writes.** Flank-identical to `helper-only`; the + distinction lives in the helper's verb constraint. No tail-anchored-Edit admission. +- **T-55 — helper loop, no exemption predicate.** A file-absent exemption would let delete-then-Write + launder arbitrary content through "the file is absent". + +**LOW — a known dead window.** Between T-33 (rows enrolled `marker-gated`) and T-49/T-50 (the minter +prose lands), no lane can legally edit `release-targets.md`. A correction in that window needs a +hand-armed marker or `/ca:override`. Expected, not a defect — do not treat the block as a failure. + +## Coverage proof + +**Every AC → at least one task.** A-1.1→T-17, A-1.2→T-18, A-1.3→T-19, A-1.4→T-20, A-1.5→T-21, +A-1.6→T-22, A-1.7→T-23, A-1.8→T-24, A-1.9→T-25/T-46, A-1.10→T-26, A-1.11→T-27a/b/c/d, A-2.1→T-28, +A-2.2→T-29, A-2.3→T-30, A-2.4→T-31, A-2.5→T-32, A-2.6→T-33, A-2.7→T-33, A-2.8→T-34, A-2.9→T-35, +A-2.10→T-36, A-3.1→T-37, A-3.2→T-38, A-3.3→T-39, A-3.4→T-40, A-3.5→T-41/T-41c, A-3.6→T-42, +A-4.1→T-43, A-4.2→T-44a/T-44b, A-4.3→T-45, A-4.4→T-46, A-5.1→T-47, A-5.2→T-48, A-5.3→T-49, +A-5.4→T-50, A-5.5→T-51, A-5.6→T-52, **A-6.0→T-41a/T-41b/T-41c/T-41d/T-41e**, A-6.1→T-68a/T-68b, +A-6.2→T-69, A-6.3→T-70, A-6.4→T-71, A-6.5→T-72, **A-6.6→T-73/T-74/T-75, A-6.7→T-76**. +B-01→T-01/02/03/15/T-05a/T-05b, B-02→T-04/T-06, B-03→T-07, B-04→T-08/T-08a, B-05→T-06/07/08/T-67, +B-06→T-13, B-07→T-09, B-08→T-10/T-08b, B-09→T-11, B-10→T-05, B-11→T-14, B-12→T-12/T-67, B-13→T-33, B-14→T-66, +B-15→T-65, B-16→T-54, B-17→T-53, B-18→T-55, B-19→T-56/T-67, B-20→T-57/T-58, B-21→T-59, B-22→T-60, +B-23→T-58/T-61, B-24→T-64, B-25→T-62, B-26→T-63, B-27→T-16. + +**Every task → at least one AC.** Verified across all 90 tasks; no task covers nothing. + +Bijective coverage proven: **72 criteria, 90 tasks**, no uncovered criterion and no orphan task. +(90 rather than 86 after the proxy rulings added T-05a, T-05b, T-08a, T-08b — the classifier +extension, the legacy-overlap guard, the marker-minting fence, and the lexical-residual pin.) + +*Rev 2/3 note — two holes were in the ledger, not the task set.* The first draft claimed bijection +over 69 criteria and 72 tasks. The claim was formally true and hollow both times: A-6.1 mapped to the +portability guard *script* while nothing rewrote the skill it polices (closed by A-6.0 + T-41x), and +#563's consumer-portability acceptance existed only as a prose checkbox, so no criterion and +therefore no task covered the thing the campaign is *for* (closed by A-6.6/A-6.7 + T-73–T-76). A +coverage proof over a criteria set with a hole in it proves the hole is consistent, nothing more. + +## Dependency order + +Strictly linear as written, with no cycle: +B1 (T-01–16) → A 1–4 (T-17–T-46, including the T-41x skill rewrite) → A 5 (T-47–52) → +B3 (T-53–56) → B4 (T-57–65) → B2 enrolment (T-66–67) → A 6 (T-68a–72). + +Ordering constraints that are not merely sequential: + +- **T-46 must not land before T-43–T-45**, or `payload_version_gate.py` breaks on every PR. T-25's + transitional test is deleted in T-46's own commit. +- **T-41a–d must land before T-68b**, since the guard cannot go green while the release skill still + carries its non-payload references. T-68b's verification names them explicitly. +- **T-58 must land before T-65.** The archive verb creates `done-tasks.md` when absent; once the file + is enrolled `append-only`, no tool write can create it. Every already-initialized repo — including + this one — never re-runs `init-codearbiter.py`, so T-61's scaffold path alone would leave the file + missing and the first archive failing. + +## Hard gates on the critical path + +After the pre-run dispositions, **two** stops remain rather than four: + +- **T-16** — ADR-0024 attribution. Content pre-approved; the halt is the signing act only. +- **Landing** — merge to the default branch; `/ca:sprint` auto-selects open-PR and never merges. + +Downgraded, with the reason recorded: + +- **T-06 – T-08** — now conditional. Halts only if `security-reviewer` reports a finding or any of + B-07…B-12 is red. +- **T-32** — no longer a stop; the boundary row text is pre-approved. + +Conditional stops that are not scheduled but may fire on genuinely new evidence: + +- **T-27c** — if the transcribed old-lane script disagrees with the live repo, that is new evidence + about the pre-change lane, not a fixup. STOP and investigate. +- **T-35** — a stale badge is not a stop (DECISION-0034 pre-decides the reconcile-and-rerun loop), but + drift revealing the catalog itself is wrong is new information. + +## Out of scope + +- `[NEEDS-TRIAGE]` D-6: whether `.github/published-tags.json` relocates to `.codearbiter/`. Tracked + in `open-questions.md`; not planned here. diff --git a/.codearbiter/specs/release-portable-fixture.md b/.codearbiter/specs/release-portable-fixture.md index 3b72168a..04cd07f4 100644 --- a/.codearbiter/specs/release-portable-fixture.md +++ b/.codearbiter/specs/release-portable-fixture.md @@ -166,9 +166,24 @@ Grouped by slice; each is one `tdd` Phase 1 obligation and individually testable `payload`, `payload_exclude[]`, `rebuild`, `artifacts[]`, `provenance_manifest`, `pre_tag[]`, `latest_eligible`, stdlib only. 1.5 An **absent** block raises a distinguishable declared error. -1.6 Each parser-contract violation raises its own distinguishable declared error: malformed block, - CRLF-bearing boolean, non-boolean boolean, duplicate scalar key, duplicate target block, unknown - key, multiple delimiter blocks, delimiter-in-value. +1.6 Each parser-contract violation raises its own distinguishable declared error: malformed block + (including a bad `[target]` header), non-boolean boolean, duplicate scalar key, duplicate target + block, unknown key, multiple delimiter blocks, delimiter-in-value. A missing required key errors + too, rather than silently defaulting. *(Rev 4.2: an earlier draft listed "CRLF-bearing boolean" + among the violations. That is the opposite — `latest-eligible: true\r` must parse cleanly to + `True`. It is a positive case and is asserted as one.)* + + **A dedicated CR-stripping pass is dead code.** Every line is independently `.strip()`-ed and + Python's `str.strip()` already removes `\r`, so a separate pass can be deleted with the CRLF + test still green. Do not reintroduce one. + + *(Rev 4.3 correction: an earlier revision of this note also claimed "a single-point mutant + cannot kill the CRLF test." That is false and was written in from an unverified report. Measured, + removing `raw_line.strip()` alone DOES kill it — and for an incidental reason: with CRLF input + the extracted block's first line is a bare `\r`, which unstripped becomes a spurious key line and + raises `MalformedBlockError` before the boolean assertion is reached. The other two strip points + do survive individually. The CRLF property therefore needs a test that asserts the parsed boolean + directly against a CRLF fixture, not one that passes because an earlier error path fires.)* 1.7 An **empty** block raises a distinguishable declared error. 1.8 Series isolation holds against loaded data: a fixture with `v1.0.0` and `ca-pi-v0.1.0` resolves each declared prefix to its own newest tag, pre-releases excluded. diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index 85f6cf0e..30e274a5 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -1215,3 +1215,4 @@ Started 2026-07-20T01:40:46-04:00. Append-only. SMARTS-scored auto-decisions; - [2026-07-31] PROXY-RULING portable-release-and-protected-state T-06..T-08 flank wiring | answered by the standing adversarial advisor under the maintainer-proxy authorization; four design questions resolved so the flank wiring does not stall, two of them by rejecting my framing | Q1 policy dispatch: CHOSEN extend `_hooklib.classify_protected` to return a new `"state"` class, with policy semantics resolved from the registry inside a single generic flank branch — NOT a parallel lookup; grounded in `_protectedlib.py:13-19`, which documents the #528/#529 failure where independent class checks on decision-log.md deadlocked because set membership was uncoordinated, and in #162 symlink laundering being closed INSIDE classify_protected (`_protectedlib.py:180-204` runs every classifier against raw and realpath forms), so a parallel lookup would ship without symlink resolution and a symlink alias would write through the guard on day one; SMARTS strong, Scalable/Maintainable/Reliable/Testable/Securable all Strong for extend vs Weak for parallel; riders: `"state"` evaluates after the existing four classes, a registry-load test asserts NO registered path classifies into any legacy class (overlap is a loud configuration error, not a precedence puzzle), and classify_protected's return contract stays a set of strings so its four existing consumers see zero change | Q2 helper-only absoluteness: CHOSEN unconditional block, confirmed safe against the full producer inventory (taskwrite.py and init-codearbiter.py use Python file I/O; boardsync.py is write-free by its own header; session-start.py reads; every skill lane routes through the task command after T-54/T-55) — but the advisor surfaced a case I had not asked about: a MERGE OR REBASE CONFLICT IN open-tasks.md ITSELF, where the resolver must edit the working-tree file and taskwrite has no resolve verb, so an absolute block turns every board conflict into a stop; ruling: block STANDS, conflict handled as a named residual routed through logged `/ca:override`, with an ADR reopen condition — if gate-events.log shows board-conflict overrides recurring, the fix is a deterministic `taskwrite resolve` verb, never a guard exception; a conflict-marker content predicate ("admit Edit when the file contains <<<<<<<") was rejected as converting file content into an authorization signal, the same launderable shape the T-55 file-absent exemption lost on | Q3 shell-flank parameterization: PREMISE REJECTED — the registry must be CODE CONSTANTS and never disk-loaded, because a disk registry would let a consumer repo un-protect open-tasks.md by editing a file; the zero-side-effects-at-import rule prohibits file I/O and git, never regex compilation from module constants, with `_scopelib.py:109-117` as direct precedent (default glob tuples precompiled at load); given that, CHOSEN per-entry regex pairs from a template `_state_write_res(basename) -> (redirect_re, write_re)` mirroring CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE shapes at `_bashguardlib.py:355-359`, compiled once at import, rather than one generic alternation whose single failure smears across all entries; verified mechanically against both pinned non-regressions (`git add open-tasks.md` passes — git is not a verb and no redirect precedes the name; the filename-in-description helper call passes — python3/taskwrite/add are not verbs; `tee` and `>>` both block); ONE RESIDUAL to document with a test rather than fix: a description containing a write verb (`taskwrite add -- "remember to tee open-tasks.md"`) false-blocks, which is the same accepted lexical residual every _bashguardlib flank carries — name it in the ADR, pin the passing form, do not chase it with smarter parsing | Q4 marker carve-out: FRAMING REJECTED — there is no exclusion to extend; `_protectedlib.py:109-113` blocks GATE_MARKER_NAMES (security-gate-passed, migration-gate-passed) only, and adr-authoring-active was never enumerated into it, because the membership criterion is whether a marker converts a BLOCK into an ALLOW; a forged gate-pass fakes a security verdict, a forged authoring marker fakes nothing since it is audit friction, self-mintable by design per ADR-0010; CHOSEN change nothing, add no generic marker handling, and instead encode the CRITERION rather than the membership — one comment on GATE_MARKER_NAMES stating that the list enumerates block-to-allow markers while friction/authoring markers stay shell-touchable by design, plus one pinning test asserting `touch .codearbiter/.markers/release-targets-authoring` passes the shell flank; the accidental-widening risk runs OPPOSITE to my question — the danger is a future maintainer "hardening" the blocklist generically over every registered marker, which would brick every minting lane while stopping no non-cooperative agent, and the pinning test is the fence against that tidy-up | confidence: high — all four grounded in cited code, two correcting my premise; none touches a non-delegable hard gate. - [2026-07-31] REVIEW portable-release-and-protected-state B1 slice (T-01..T-05) | Opus adversarial pass per the maintainer's standard; 13-mutant campaign against the 9 new tests, 7 survivors, 2 HIGH | verdict: no live defect and no security hole — traversal safety, freshness delegation, genericity and zero-side-effects-at-import all survived attack, the latter verified under sys.addaudithook rather than assumed — but TEST ADEQUACY failed against the standing "every new test must die to a mutant" directive | HIGH-1: the default-registry code path has no fail-detecting test; mutating `registry = REGISTRY` to `registry = {}` passes all 9, because the only default-path test is a NEGATIVE assertion against one literal path and all three positive tests inject a synthetic registry — meaning nothing proves the production path works, and once consumers enrol at B-13/14/15 the entire guard could be a silent no-op with CI green | HIGH-2: the freshness window is a tautology in both directions; MARKER_FRESHNESS_MINUTES mutated to 1000 or 3 passes all 9, because both freshness tests compute their ages FROM the implementation constant, pinning boundary logic but not value — a 33x widening of a security window would ship green; compounded by a comment claiming the constant is "reused verbatim (not re-declared)" when 30 is in fact hardcoded in five independent places with no import relationship | ORCHESTRATOR ERROR, recorded deliberately: MEDIUM-3 established that my own earlier "fix" was wrong. I removed `REGISTRY: dict[str, ProtectedPolicy]` believing a PEP 585 builtin generic is evaluated at import and raises on Python 3.8, and reported that to the maintainer as a confirmed defect CI could not catch. The module carries `from __future__ import annotations` at line 84, which under PEP 563 leaves VARIABLE annotations unevaluated as well — verified by probe: the annotation with an undefined name raises nothing. The annotation was always safe on 3.8. Worse than the unnecessary change, the comment I left asserted "Every sibling _*lib.py declares module dicts bare for the same reason", a false rule that would have propagated into the next four modules of this campaign. Remediation restores the annotation and deletes the comment | remaining MEDIUMs sent for fix rather than filing, because all four propagate into the next four modules: a typo'd registry KEY fails open and silent (norm_path applied to the query, never the keys) which directly contradicts the module's own stated doctrine that malformed input must fail loud; the `str` mixin is entirely untested and its docstring's argv claim is false on 3.11+ where interpolation yields the class-qualified name and varies by interpreter version; `marker_name_for` is basename-only so two registered files sharing a stem share one authoring marker; and its norm_path call is untested | LOWs at discretion: dead `minutes` parameter, and the call-site constraint (module must be reached through classify_protected, never called directly by a flank) documented only in this log | confidence: high — every finding reproduced by executed mutation, not asserted. - [2026-07-31] SECURITY-GATE portable-release-and-protected-state H-22 flank wiring | security-reviewer verdict PASS, 0 CRITICAL 0 HIGH — the tripwire disposition therefore holds and autonomous execution continues without a maintainer stop | cleared by execution rather than by reading: the registry-is-code-constant property genuinely holds (REGISTRY assigned once as a literal, no json.load/open/env read anywhere in core/pysrc); the deferred import is necessary rather than stylistic and FAILS CLOSED (at call time it is a sys.modules hit since all three flanks already import the module at scope; a raise propagates into the except backstop and becomes block H-00 exit 2); every traced fail direction is correct (registry raise, unreadable marker dir, unresolvable root, cross-drive path, resolve returning None — all closed; genuinely-outside-repo correctly allows); and both load-bearing non-regressions are proved rather than accidental, with the regex-level cause pinned independently | the `touch` exclusion was CLEARED with evidence rather than accepted on precedent: touch cannot truncate on any supported platform, the PowerShell verbs that CAN truncate are all blocked, and every mtime consumer under .codearbiter/ was traced — the statusline key fails toward MORE work not less, staleness_warning does not key on registry paths, and marker_gated_write_admitted stats the MARKER not the protected file, so even `touch -t` back-dating cannot admit anything; the H-11 precedent that DOES include touch is principled (there, creation itself is the violation; here the helper creates the file legitimately) | THREE MEDIUMS, all latent while the registry is empty and all going live at B-13/B-14/B-15 — and those enrolment tasks will not re-run this review, which is the reviewer's argument for closing them inside this sprint rather than filing | M-1: lookup_policy matches by exact equality after separator normalization only, while its own docstring claims a `./` prefix or trailing slash still matches; measured, `./.codearbiter/open-tasks.md`, a trailing slash, a doubled slash and a leading space all MISS, so a mis-keyed entry protects nothing while LOOKING protected in manual testing because the shell flank keys off the bare basename and still fires | M-2, the more serious: the raw leg of the two-form lookup is INERT in the wired path because hosts always send absolute paths, so the state class rests entirely on repo_rel — which inverts symlink coverage exactly where the design claims to close #162; measured, when the protected path is itself a symlink the ABSOLUTE spelling (the only one a host sends) is ADMITTED while the relative one blocks, and the legacy regex classes are immune because search() matches the raw path regardless; the non-adversarial case is the concerning one, a consumer symlinking their board into a monorepo location loses H-22 silently with no diagnostic | M-3: the write-verb list is narrower than the H-05 precedent it cites as its model, leaving nine spellings allowed including `git checkout HEAD -- ` and `git restore` (closed for audit logs by LOG_GIT_RESTORE_RE under #335 with the rationale that they rewrite tracked files through Git itself, and all three planned entries are tracked), `sponge` (present in LOG_DESTROY_RE, dropped here), and `python -c`/`node -e` — the last directly contradicting B1's own stated premise for helper-only, that the sanctioned helper's Python file I/O is the ONLY route, since `python -c` naming the file lexically is exactly that; these are currently UNDECLARED residual rather than accepted residual | disposition: hold remediation until the parallel Opus adversarial pass lands, then remediate once against both reports rather than twice | confidence: high — every finding measured against a synthetic registry, not inferred. +- [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-17..T-24 mechanism split | Opus adversarial pass: 46 mutants, 17 killed, 3 HIGH; all HIGH and 5 MEDIUM remediated, suite grew from 112 to 168 tests, sync-core byte-identical across 55 core files and 3 plugins | H-3 was the structurally interesting one: seven of the nine mechanism functions had ZERO coverage in the new module. Each could be replaced with a hardcoded constant return and all 112 tests stayed green, because the ~100 tests exercising those names ran against the OLD helper, the file this slice deliberately did not touch. The suite looked like it covered the migration and in fact covered the thing being migrated FROM. The reviewer then ran a ~1,900-input differential across both modules and found ZERO behavioral drift, so the split was correct all along, but that proof lived in a throwaway harness and nothing in the repo would have shown it. Right code, absent verification, which is the state that looks safest and is not | M-2 is the finding that justifies the campaign: the pre-release marker filter substring-tested "-beta", "-rc" and "-alpha" against the WHOLE TAG INCLUDING THE PREFIX. Unreachable in this repo because the anchored regex already excludes suffixed tags, which is why mutating it away survived. But a consumer whose prefix contains a marker loses every tag in that series and receives the "never released" sentinel, so the lane would offer a first-release baseline to a project that has releases. Verified fixed against the SHIPPED plugin copy: a web-beta-v prefix now resolves web-beta-v1.2.0 where it previously returned the sentinel, while a plain v prefix still correctly excludes v1.1.0-beta. A portability defect of exactly the class this split exists to remove, sitting inside the code doing the removing | H-1 silently truncated a block when a value contained the closing delimiter and it was the only close, parsing as success with a required key emptied, and separately misdiagnosed legitimate trailing prose; both directions fixed with a genuine-terminator scan | H-2: empty and whitespace-only values satisfied the required-key check because the row finisher tested "is None" while a parsed key always assigns a string, so a typo'd empty prefix became "" and flowed onward as "never released" | ORCHESTRATOR ERROR, second of this campaign: I wrote an unverified subagent claim into the spec as normative text, namely that a single-point mutant cannot kill the CRLF test. Measured, removing the raw-line strip alone DOES kill it, and for an incidental reason: with CRLF input the extracted block's first line is a bare carriage return which unstripped becomes a spurious key line and raises MalformedBlockError before the boolean assertion is reached. The dead-code half of the claim was true; the normative half was not, and it had become spec text instructing maintainers not to touch it. Corrected as rev 4.3. Same failure mode as the PEP 585 error earlier in this campaign: a specific, mechanically plausible claim arriving inside otherwise careful work, written down without the two-line probe that would have falsified it | also fixed: the missing-required-key error and the first-colon split rule were both untested, two escape hatches raised bare TypeError and FileNotFoundError outside the declared error hierarchy, a scalar-keys constant was dead code, and boolean parsing accepted TRUE against a spec requiring exactly true or false | confidence: high, every fix probed against the SHIPPED plugin copy rather than the source. diff --git a/.github/scripts/test_release_lib.py b/.github/scripts/test_release_lib.py index 40ebf8ac..d54e5bc2 100644 --- a/.github/scripts/test_release_lib.py +++ b/.github/scripts/test_release_lib.py @@ -12,8 +12,41 @@ The helpers are pure functions exercised with synthetic input — no git, no I/O. Stdlib only. Exit 0 = all pass; non-zero = failure. + +Portable-release split (issue #563, .codearbiter/specs/release-portable-fixture.md, +slice 1). The classes below this point exercise `core/pysrc/_releaselib.py` — the +PORTABLE mechanism module, loaded under a distinct module name (`_core_releaselib`) +so it never collides with `.github/scripts/_releaselib.py` (the repo's own, +data-carrying, currently-unmodified shim) imported above as `_releaselib`: + + DenylistTest A-1.2 — no repo-namespace/CI-vocabulary literal in the mechanism + RequiredParamsTest A-1.3 — repo defaults became required parameters + LoadTargetsTest A-1.4 — load_targets(path) returns the full row schema + AbsentBlockTest A-1.5 — an absent delimiter block raises a declared error + ParserContractTest A-1.6 — each parser-contract violation raises its own error + EmptyBlockTest A-1.7 — an empty delimiter block raises a declared error + SeriesIsolationTest A-1.8 — series isolation against loaded target data + +Adversarial-review remediation (2026-07-31, mutation campaign against +core/pysrc/_releaselib.py). Each class below exercises `core_releaselib` +directly, closing the gap where the mechanism shipped in a new module but its +proof still lived only against the old, unmodified shim: + + CoreDelimiterInValueTest H1 — value-embedded delimiter errors, both directions + CoreRequiredKeyEmptyValueTest H2 — an empty/whitespace required-key value is MISSING + CoreClassifyPublishStateTest H3 — classify_publish_state against the portable module + CoreNotesHeadingTest H3 — notes_heading_matches / _bare_version, ditto + CoreReleaseDatesTest H3 — release_dates_consistent, ditto + CorePeelTagTest H3 — peel_tag, ditto + CoreSemverTest H3 — semver_key / semver_greater, previously untested anywhere + CoreMergeReadinessArmsTest H3 — classify_merge_readiness's 4 non-green arms + CoreSelectReleaseTargetArmsTest H3 — select_release_target's arity/multiple arms + CorePrereleaseMarkerScopeTest M2 — marker denylist scoped to the version, not the prefix + CoreColonInValueTest M4 — values split on the FIRST colon only + CoreEscapeHatchWrappingTest M5 — non-string input / unreadable path stay in-hierarchy """ +import importlib.util import os import sys import unittest @@ -23,6 +56,13 @@ import _releaselib # noqa: E402 — needs sys.path mutation above +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +_CORE_RELEASELIB_PATH = os.path.join(REPO_ROOT, "core", "pysrc", "_releaselib.py") +_core_spec = importlib.util.spec_from_file_location( + "_core_releaselib", _CORE_RELEASELIB_PATH) +core_releaselib = importlib.util.module_from_spec(_core_spec) +_core_spec.loader.exec_module(core_releaselib) + class LastTagSelectTest(unittest.TestCase): """AC-1 (0006): highest ca SemVer tag, excluding pre-releases and ca-sandbox-v*.""" @@ -729,5 +769,797 @@ def test_bare_version_extracts_the_semver_from_any_spelling(self): with self.subTest(spelling=spelling): self.assertEqual(_releaselib._bare_version(spelling), want) +class DenylistTest(unittest.TestCase): + """A-1.2: the portable mechanism (core/pysrc/_releaselib.py) must carry no + literal from this repository's namespace or CI vocabulary. `.github/ + scripts/_releaselib.py` (imported above as `_releaselib`) is EXEMPT — it + still temporarily retains this repo's own data constants (A-1.9, + transitional) and is deliberately not scanned here.""" + + DENYLIST = ( + "[REPO]", "ca-pi", "ca-codex", "ca-sandbox", "plugins/", + "RELEASE_TAG_PREFIXES", "RELEASE_TARGETS", + ) + + @classmethod + def setUpClass(cls): + with open(_CORE_RELEASELIB_PATH, encoding="utf-8") as fh: + cls.text = fh.read() + + def test_denylist_has_no_repo_namespace_or_ci_vocabulary_literal(self): + for token in self.DENYLIST: + with self.subTest(token=token): + self.assertNotIn( + token, self.text, + f"core/pysrc/_releaselib.py must not contain the " + f"repo-specific literal {token!r}") + + +class RequiredParamsTest(unittest.TestCase): + """A-1.3: repo-specific defaults became required parameters, so no + module-global fact can survive to detonate later. Each of the three + functions must reject a call missing the argument that used to default.""" + + def test_required_params_classify_merge_readiness_requires_check_name(self): + with self.assertRaises(TypeError): + core_releaselib.classify_merge_readiness([], "a" * 40) + + def test_required_params_last_tag_select_requires_prefix(self): + with self.assertRaises(TypeError): + core_releaselib.last_tag_select(["v1.0.0"]) + + def test_required_params_select_release_target_requires_targets(self): + with self.assertRaises(TypeError): + core_releaselib.select_release_target("1.0.0", "") + + def test_required_params_still_work_when_supplied(self): + # Non-regression: removing the default must not have broken the + # happy path, only removed the silent fallback. + self.assertEqual( + core_releaselib.last_tag_select(["v1.0.0"], "v"), "v1.0.0") + self.assertEqual( + core_releaselib.classify_merge_readiness( + [{"name": "gate", "head_sha": "a" * 40, "status": "completed", + "conclusion": "success"}], "a" * 40, "gate"), + "green") + self.assertEqual( + core_releaselib.select_release_target( + "1.0.0", "", targets=["app", "lib"]), + "app") + + +class LoadTargetsTest(unittest.TestCase): + """A-1.4: load_targets(path) returns rows carrying the full declared + schema, reading a real file from disk (stdlib only).""" + + FIXTURE = ( + "prose before the block is ignored\n\n" + "\n" + "[app]\n" + "prefix: v\n" + "manifest: package.json\n" + "manifest: nested/package.json\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "payload-exclude: tools/\n" + "rebuild: npm run build\n" + "artifacts: dist/bundle.js\n" + "artifacts: dist/bundle.min.js\n" + "provenance-manifest: .well-known/published-tags.json\n" + "latest-eligible: true\n" + "pre-tag: npm run lint\n" + "pre-tag: npm run typecheck\n" + "\n" + "prose after the block is ignored\n" + ) + + def _write_fixture(self, tmp_path_dir, text=None): + import tempfile + fd, path = tempfile.mkstemp(suffix=".md", dir=tmp_path_dir) + with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh: + fh.write(text if text is not None else self.FIXTURE) + return path + + def test_load_targets_returns_the_full_row_schema(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = self._write_fixture(tmp) + rows = core_releaselib.load_targets(path) + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["target"], "app") + self.assertEqual(row["prefix"], "v") + self.assertEqual(row["manifest"], ["package.json", "nested/package.json"]) + self.assertEqual(row["changelog"], "CHANGELOG.md") + self.assertEqual(row["payload"], ".") + self.assertEqual(row["payload_exclude"], ["tools/"]) + self.assertEqual(row["rebuild"], "npm run build") + self.assertEqual(row["artifacts"], ["dist/bundle.js", "dist/bundle.min.js"]) + self.assertEqual(row["provenance_manifest"], ".well-known/published-tags.json") + self.assertEqual(row["pre_tag"], ["npm run lint", "npm run typecheck"]) + self.assertIs(row["latest_eligible"], True) + + def test_load_targets_minimal_row_has_documented_defaults(self): + text = ("\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n") + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = self._write_fixture(tmp, text) + rows = core_releaselib.load_targets(path) + row = rows[0] + self.assertEqual(row["manifest"], []) + self.assertEqual(row["payload_exclude"], []) + self.assertIsNone(row["rebuild"]) + self.assertEqual(row["artifacts"], []) + self.assertIsNone(row["provenance_manifest"]) + self.assertEqual(row["pre_tag"], []) + self.assertIs(row["latest_eligible"], False) + + def test_load_targets_multiple_targets_all_load(self): + text = ("\n" + "[one]\n" + "prefix: one-v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" + "[two]\n" + "prefix: two-v\n" + "changelog: two/CHANGELOG.md\n" + "payload: two/\n" + "\n") + import tempfile + with tempfile.TemporaryDirectory() as tmp: + path = self._write_fixture(tmp, text) + rows = core_releaselib.load_targets(path) + self.assertEqual([row["target"] for row in rows], ["one", "two"]) + + +class AbsentBlockTest(unittest.TestCase): + """A-1.5: an absent delimiter block raises a distinguishable declared + error, never a silent empty result.""" + + def test_no_delimiter_at_all_raises_absent_block_error(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets("just some prose, no block\n") + + def test_empty_string_raises_absent_block_error(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets("") + + def test_absent_block_error_is_a_release_targets_error(self): + # Callers that only need the broad category can catch the base class. + self.assertTrue( + issubclass(core_releaselib.AbsentBlockError, + core_releaselib.ReleaseTargetsError)) + + +class EmptyBlockTest(unittest.TestCase): + """A-1.7: a present-but-empty delimiter block raises a distinguishable + declared error, distinct from AbsentBlockError.""" + + def test_whitespace_only_block_raises_empty_block_error(self): + text = "\n \n\n\n" + with self.assertRaises(core_releaselib.EmptyBlockError): + core_releaselib.parse_release_targets(text) + + def test_zero_byte_block_raises_empty_block_error(self): + text = "" + with self.assertRaises(core_releaselib.EmptyBlockError): + core_releaselib.parse_release_targets(text) + + def test_empty_block_error_is_distinct_from_absent_block_error(self): + self.assertFalse( + issubclass(core_releaselib.EmptyBlockError, + core_releaselib.AbsentBlockError)) + self.assertFalse( + issubclass(core_releaselib.AbsentBlockError, + core_releaselib.EmptyBlockError)) + + +class ParserContractTest(unittest.TestCase): + """A-1.6: each parser-contract violation raises its own distinguishable + declared error. Eight cases, matching the spec's enumeration: malformed + block, CRLF-bearing boolean (correct-parse case, not an error), non- + boolean boolean, duplicate scalar key, duplicate target block, unknown + key, multiple delimiter blocks, delimiter-in-value.""" + + VALID_HEADER = ("\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n") + CLOSE = "\n" + + def test_parser_contract_malformed_block_empty_header(self): + text = ("\n[]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + self.CLOSE) + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_malformed_block_header_bad_characters(self): + text = ("\n[app name!]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + self.CLOSE) + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_malformed_block_key_before_first_header(self): + text = ("\n" + "prefix: v\n[app]\nchangelog: CHANGELOG.md\npayload: .\n" + + self.CLOSE) + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_crlf_bearing_boolean_parses_as_the_boolean_not_an_error(self): + # This repo has documented LF->CRLF drift from editing on Windows. A + # naive parser would turn "latest-eligible: true\r" into a value that + # is not "true" and either silently drop the flag or raise the wrong + # error. The trailing \r must be stripped BEFORE the boolean check. + text = ("\r\n[app]\r\nprefix: v\r\n" + "changelog: CHANGELOG.md\r\npayload: .\r\n" + "latest-eligible: true\r\n" + "\r\n") + rows = core_releaselib.parse_release_targets(text) + self.assertIs(rows[0]["latest_eligible"], True) + + def test_parser_contract_non_boolean_boolean_raises_invalid_boolean_error(self): + text = self.VALID_HEADER + "latest-eligible: yes\n" + self.CLOSE + with self.assertRaises(core_releaselib.InvalidBooleanError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_duplicate_scalar_key_raises_duplicate_key_error(self): + text = self.VALID_HEADER + "prefix: w\n" + self.CLOSE + with self.assertRaises(core_releaselib.DuplicateKeyError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_list_keys_repeat_without_error(self): + # Non-regression: manifest/artifacts/pre-tag/payload-exclude are + # explicitly NOT subject to the duplicate-scalar-key rule. + text = (self.VALID_HEADER + "manifest: a.json\nmanifest: b.json\n" + + self.CLOSE) + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["manifest"], ["a.json", "b.json"]) + + def test_parser_contract_duplicate_target_block_raises_duplicate_target_error(self): + text = (self.VALID_HEADER + "[app]\nprefix: w\n" + "changelog: CHANGELOG.md\npayload: .\n" + self.CLOSE) + with self.assertRaises(core_releaselib.DuplicateTargetError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_unknown_key_raises_unknown_key_error(self): + text = self.VALID_HEADER + "latest-eligibile: true\n" + self.CLOSE + with self.assertRaises(core_releaselib.UnknownKeyError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_multiple_delimiter_blocks_raises_multiple_blocks_error(self): + text = (self.VALID_HEADER + self.CLOSE + "\nmore prose\n\n" + + self.VALID_HEADER + self.CLOSE) + with self.assertRaises(core_releaselib.MultipleBlocksError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_delimiter_in_value_raises_delimiter_in_value_error(self): + # A value that literally contains the closing delimiter must not + # silently truncate the block under a non-greedy match. + text = (self.VALID_HEADER + + "rebuild: echo ''\n" + self.CLOSE) + with self.assertRaises(core_releaselib.DelimiterInValueError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_missing_required_key_raises_missing_required_key_error(self): + # M3: previously untested — gutting _finish_row's check, or shrinking + # _REQUIRED_KEYS to one entry, both left the suite green. Each of the + # three required keys is dropped in its own fixture so a shrink of + # _REQUIRED_KEYS to any single remaining entry is still caught, + # rather than only a shrink that happens to keep the one key this + # class's fixtures omit. + cases = { + "prefix": "changelog: CHANGELOG.md\npayload: .\n", + "changelog": "prefix: v\npayload: .\n", + "payload": "prefix: v\nchangelog: CHANGELOG.md\n", + } + for missing_key, body in cases.items(): + with self.subTest(missing=missing_key): + text = "\n[app]\n" + body + self.CLOSE + with self.assertRaises(core_releaselib.MissingRequiredKeyError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_boolean_is_exact_case_true_or_false_only(self): + # M6: `value == "true"` mutated to a case-insensitive comparison + # (`value.lower() == "true"`) survived the suite otherwise — the + # spec requires the boolean to be EXACTLY "true"/"false". + text = self.VALID_HEADER + "latest-eligible: TRUE\n" + self.CLOSE + with self.assertRaises(core_releaselib.InvalidBooleanError): + core_releaselib.parse_release_targets(text) + + def test_parser_contract_unclosed_block_raises_malformed_block_error(self): + # M6: the unclosed-block branch (no `` at + # all) was previously untested directly. + with self.assertRaises(core_releaselib.MalformedBlockError): + core_releaselib.parse_release_targets(self.VALID_HEADER) + + def test_parser_contract_every_violation_is_its_own_distinguishable_type(self): + # The point of the contract: a caller can tell these apart by type. + types = { + core_releaselib.MalformedBlockError, + core_releaselib.InvalidBooleanError, + core_releaselib.DuplicateKeyError, + core_releaselib.DuplicateTargetError, + core_releaselib.UnknownKeyError, + core_releaselib.MultipleBlocksError, + core_releaselib.DelimiterInValueError, + core_releaselib.MissingRequiredKeyError, + } + self.assertEqual(len(types), 8, "the 8 non-CRLF violations must be distinct types") + for cls in types: + with self.subTest(cls=cls.__name__): + self.assertTrue(issubclass(cls, core_releaselib.ReleaseTargetsError)) + + +class SeriesIsolationTest(unittest.TestCase): + """A-1.8: series isolation holds against LOADED data — a fixture + declaring two series resolves each declared prefix to its own newest + tag, with pre-releases excluded, composing `load_targets` with + `last_tag_select`.""" + + FIXTURE = ( + "\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n" + "payload: .\n" + "\n" + "[app-pi]\n" + "prefix: ca-pi-v\n" + "changelog: sub/CHANGELOG.md\n" + "payload: sub/\n" + "\n" + ) + + TAGS = [ + "v1.0.0", "v0.9.0", "v1.1.0-beta.1", + "ca-pi-v0.1.0", "ca-pi-v0.0.9", "ca-pi-v0.2.0-beta.1", + ] + + def test_series_isolation_each_series_resolves_its_own_newest_tag(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + import os as _os + path = _os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self.FIXTURE) + rows = core_releaselib.load_targets(path) + + resolved = { + row["target"]: core_releaselib.last_tag_select(self.TAGS, row["prefix"]) + for row in rows + } + self.assertEqual(resolved["app"], "v1.0.0") + self.assertEqual(resolved["app-pi"], "ca-pi-v0.1.0") + + def test_series_isolation_prereleases_excluded_from_both_series(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + import os as _os + path = _os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self.FIXTURE) + rows = core_releaselib.load_targets(path) + + for row in rows: + chosen = core_releaselib.last_tag_select(self.TAGS, row["prefix"]) + with self.subTest(target=row["target"]): + self.assertNotIn("beta", chosen) + + def test_series_isolation_no_series_resolves_the_others_tag(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + import os as _os + path = _os.path.join(tmp, "release-targets.md") + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self.FIXTURE) + rows = core_releaselib.load_targets(path) + + prefixes = [row["prefix"] for row in rows] + for row in rows: + chosen = core_releaselib.last_tag_select(self.TAGS, row["prefix"]) + for other_prefix in prefixes: + if other_prefix == row["prefix"]: + continue + with self.subTest(target=row["target"], other=other_prefix): + self.assertFalse(chosen.startswith(other_prefix)) + + +# --------------------------------------------------------------------------- # +# Adversarial-review remediation (2026-07-31). See the module docstring for +# the finding each class closes. Every class here loads `core_releaselib` +# directly (never the old, unmodified `.github/scripts/_releaselib.py` shim), +# because the mutation gap this remediates was exactly that: real logic now +# ships in core/pysrc/_releaselib.py, but its only proof lived against the +# untouched shim. +# --------------------------------------------------------------------------- # + + +class CoreDelimiterInValueTest(unittest.TestCase): + """H1: a value containing the literal closing delimiter must error in BOTH + directions — never silently truncate (even when the embedded delimiter is + the ONLY close in the text), and never misdiagnose a legitimate stray + mention of the delimiter text in prose that follows a properly-closed + block.""" + + HEADER = ("\n" + "[app]\n" + "prefix: v\n" + "changelog: CHANGELOG.md\n") + + def test_a_non_required_value_embedding_the_delimiter_errors_not_truncates(self): + # Was: parses OK with rebuild == 'echo' (silently truncated). + text = self.HEADER + "payload: .\nrebuild: echo \n" + with self.assertRaises(core_releaselib.DelimiterInValueError): + core_releaselib.parse_release_targets(text) + + def test_a_required_value_embedding_the_delimiter_errors_not_emptied(self): + # Was: parses OK with payload == '' — a REQUIRED key silently emptied, + # the worst case the finding names. + text = self.HEADER + "payload: \n" + with self.assertRaises(core_releaselib.DelimiterInValueError): + core_releaselib.parse_release_targets(text) + + def test_a_stray_close_in_trailing_prose_is_not_misdiagnosed(self): + # Two close-delimiter matches exist in the text, but the FIRST one is + # the genuine, own-line block terminator; the second is a legitimate + # mention in prose AFTER the block. Only the direction above (a match + # embedded mid-line, encountered BEFORE any genuine terminator) is a + # violation. + text = (self.HEADER + "payload: .\n" + "\n" + "\n" + "See also in this doc as an example.\n") + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["payload"], ".") + + +class CoreRequiredKeyEmptyValueTest(unittest.TestCase): + """H2: a required key present with an empty or whitespace-only value must + be treated as MISSING. An `is None` check alone lets `prefix:` (no value) + through as `''`, which then resolves `last_tag_select`'s `` + sentinel — a typo'd declaration silently becomes a first-release + baseline.""" + + def test_empty_value_on_a_required_key_is_missing(self): + text = ("\n[app]\n" + "prefix:\n" + "changelog: CHANGELOG.md\npayload: .\n" + "\n") + with self.assertRaises(core_releaselib.MissingRequiredKeyError): + core_releaselib.parse_release_targets(text) + + def test_whitespace_only_value_on_a_required_key_is_missing(self): + text = ("\n[app]\n" + "prefix: \n" + "changelog: CHANGELOG.md\npayload: .\n" + "\n") + with self.assertRaises(core_releaselib.MissingRequiredKeyError): + core_releaselib.parse_release_targets(text) + + def test_a_genuinely_present_value_still_passes(self): + # Non-regression: the stricter check must not reject real content. + text = ("\n[app]\n" + "prefix: v\nchangelog: CHANGELOG.md\npayload: .\n" + "\n") + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["prefix"], "v") + + +class CorePrereleaseMarkerScopeTest(unittest.TestCase): + """M2: the pre-release marker denylist must test the VERSION portion + only, never the whole tag (prefix included) — a consumer whose own + prefix happens to contain one of the marker substrings must not lose + every release tag in that series.""" + + def test_a_prefix_containing_beta_still_resolves_its_own_releases(self): + tags = ["web-beta-v1.0.0", "web-beta-v1.2.0"] + self.assertEqual( + core_releaselib.last_tag_select(tags, "web-beta-v"), "web-beta-v1.2.0") + + def test_a_prefix_containing_rc_still_resolves_its_own_releases(self): + tags = ["api-rc-v2.0.0", "api-rc-v2.1.0"] + self.assertEqual( + core_releaselib.last_tag_select(tags, "api-rc-v"), "api-rc-v2.1.0") + + def test_a_prefix_containing_alpha_still_resolves_its_own_releases(self): + tags = ["thing-alpha-v0.1.0", "thing-alpha-v0.2.0"] + self.assertEqual( + core_releaselib.last_tag_select(tags, "thing-alpha-v"), "thing-alpha-v0.2.0") + + +class CoreColonInValueTest(unittest.TestCase): + """M4: values split on the FIRST colon only; a later colon is part of the + value. `line.find(":")` vs `line.rfind(":")` are indistinguishable + without a fixture value that itself contains a colon.""" + + def test_a_value_containing_a_colon_is_preserved_whole(self): + text = ("\n[app]\n" + "prefix: v\nchangelog: CHANGELOG.md\npayload: .\n" + 'rebuild: echo "a:b" && date +%H:%M:%S\n' + "\n") + rows = core_releaselib.parse_release_targets(text) + self.assertEqual(rows[0]["rebuild"], 'echo "a:b" && date +%H:%M:%S') + + +class CoreEscapeHatchWrappingTest(unittest.TestCase): + """M5: two escape hatches must not bypass the declared ReleaseTargetsError + hierarchy the module promises callers can catch as one type.""" + + def test_non_string_input_stays_in_the_declared_hierarchy(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.parse_release_targets(None) + + def test_an_unreadable_path_stays_in_the_declared_hierarchy(self): + with self.assertRaises(core_releaselib.AbsentBlockError): + core_releaselib.load_targets("no/such/release-targets.md") + + +class CoreClassifyPublishStateTest(unittest.TestCase): + """H3: classify_publish_state exercised against the portable module — + previously only the (untouched) shim's copy carried this proof.""" + + def test_no_tag_is_publish_fresh(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=False, tag_sha=None, head_sha="abc", + tag_version=None, manifest_version="2.6.0", release_is_nondraft=False), + "publish_fresh") + + def test_tag_at_head_version_match_no_release_is_resume(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="abc", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=False), + "resume_publish") + + def test_nondraft_release_is_already_published(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="abc", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=True), + "already_published") + + def test_tag_points_elsewhere_is_abort(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="xyz", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=False), + "abort_mismatch") + + def test_version_mismatch_is_abort(self): + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="abc", head_sha="abc", + tag_version="2.5.0", manifest_version="2.6.0", release_is_nondraft=False), + "abort_mismatch") + + def test_nondraft_release_on_a_tag_at_another_commit_is_abort(self): + # Mismatch outranks publication state (issue #380's original defect). + self.assertEqual( + core_releaselib.classify_publish_state( + tag_exists=True, tag_sha="xyz", head_sha="abc", + tag_version="2.6.0", manifest_version="2.6.0", release_is_nondraft=True), + "abort_mismatch") + + +class CoreNotesHeadingTest(unittest.TestCase): + """H3: notes_heading_matches / _bare_version exercised against the + portable module.""" + + def test_matching_heading(self): + notes = "## v2.6.0 — 2026-06-26\n\n### Added\n- thing\n" + self.assertTrue(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_matching_bracket_heading(self): + notes = "## [2.6.0] — 2026-06-27\n\n### Added\n- thing\n" + self.assertTrue(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_mismatched_heading(self): + notes = "## v2.5.0 — 2026-06-01\n\n### Fixed\n- bug\n" + self.assertFalse(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_first_heading_is_authoritative(self): + notes = "## v2.5.0 — 2026-06-01\n\n## v2.6.0 — 2026-06-26\n" + self.assertFalse(core_releaselib.notes_heading_matches(notes, "v2.6.0")) + + def test_no_heading_is_false(self): + self.assertFalse( + core_releaselib.notes_heading_matches("no headings here", "v2.6.0")) + + def test_never_raises_on_non_string(self): + self.assertFalse(core_releaselib.notes_heading_matches(None, "v2.6.0")) + + def test_namespaced_tag_matches_via_bare_version(self): + notes = "## [0.1.31] - 2026-07-26\n\n### Added\n\n- a thing\n" + self.assertTrue( + core_releaselib.notes_heading_matches(notes, "ca-pi-v0.1.31")) + + def test_bare_version_extracts_the_semver_from_any_spelling(self): + cases = { + "v2.6.0": "2.6.0", + "2.6.0": "2.6.0", + "[2.6.0]": "2.6.0", + "ca-pi-v0.1.31": "0.1.31", + } + for spelling, want in cases.items(): + with self.subTest(spelling=spelling): + self.assertEqual(core_releaselib._bare_version(spelling), want) + + def test_bare_version_never_raises_on_non_string(self): + self.assertIsNone(core_releaselib._bare_version(None)) + + +class CoreReleaseDatesTest(unittest.TestCase): + """H3: release_dates_consistent exercised against the portable module.""" + + def test_consistent_dates(self): + section = "## v2.6.0 — 2026-06-26\n\n### Added\n- thing\n" + tagmsg = "codeArbiter 2.6.0\n\nstuff\n\nReleased-at: 2026-06-26\n" + self.assertTrue(core_releaselib.release_dates_consistent(section, tagmsg)) + + def test_inconsistent_dates(self): + section = "## v2.6.0 — 2026-06-26\n" + tagmsg = "Released-at: 2026-06-25\n" + self.assertFalse(core_releaselib.release_dates_consistent(section, tagmsg)) + + def test_missing_changelog_date_is_false(self): + self.assertFalse(core_releaselib.release_dates_consistent( + "## v2.6.0\n", "Released-at: 2026-06-26\n")) + + def test_missing_tag_date_is_false(self): + self.assertFalse(core_releaselib.release_dates_consistent( + "## v2.6.0 — 2026-06-26\n", "no footer")) + + def test_never_raises(self): + self.assertFalse(core_releaselib.release_dates_consistent(None, None)) + + +class CorePeelTagTest(unittest.TestCase): + """H3: peel_tag exercised against the portable module.""" + + TAG_OBJ = "1" * 40 + COMMIT = "2" * 40 + + def test_annotated_tag_resolves_to_the_peeled_commit(self): + text = (f"{self.TAG_OBJ}\trefs/tags/v2.6.0\n" + f"{self.COMMIT}\trefs/tags/v2.6.0^{{}}\n") + self.assertEqual(core_releaselib.peel_tag(text, "v2.6.0"), self.COMMIT) + + def test_lightweight_tag_resolves_to_its_direct_target(self): + text = f"{self.COMMIT}\trefs/tags/v2.6.0\n" + self.assertEqual(core_releaselib.peel_tag(text, "v2.6.0"), self.COMMIT) + + def test_a_prefix_sharing_tag_is_not_mistaken_for_it(self): + text = f"{self.COMMIT}\trefs/tags/v2.6.0-beta.1\n" + self.assertEqual(core_releaselib.peel_tag(text, "v2.6.0"), "") + + def test_absent_tag_is_empty(self): + self.assertEqual(core_releaselib.peel_tag("", "v2.6.0"), "") + + def test_never_raises_on_non_string(self): + self.assertEqual(core_releaselib.peel_tag(None, "v2.6.0"), "") + self.assertEqual(core_releaselib.peel_tag("whatever", None), "") + + +class CoreSemverTest(unittest.TestCase): + """H3: semver_key / semver_greater exercised against the portable + module — previously untested against ANY module, old shim included.""" + + def test_semver_key_parses_a_plain_version(self): + self.assertEqual(core_releaselib.semver_key("2.9.1"), (2, 9, 1, None)) + + def test_semver_key_none_on_unparseable(self): + self.assertIsNone(core_releaselib.semver_key("not-a-version")) + self.assertIsNone(core_releaselib.semver_key(None)) + self.assertIsNone(core_releaselib.semver_key(42)) + + def test_semver_key_discards_build_metadata_for_equality(self): + self.assertEqual( + core_releaselib.semver_key("1.0.0+a"), + core_releaselib.semver_key("1.0.0+b")) + + def test_semver_greater_numeric_advance(self): + self.assertTrue(core_releaselib.semver_greater("2.10.0", "2.9.0")) + self.assertFalse(core_releaselib.semver_greater("2.9.0", "2.10.0")) + self.assertFalse(core_releaselib.semver_greater("2.9.0", "2.9.0")) + + def test_semver_greater_release_beats_its_own_prerelease(self): + self.assertTrue(core_releaselib.semver_greater("1.0.0", "1.0.0-beta")) + self.assertFalse(core_releaselib.semver_greater("1.0.0-beta", "1.0.0")) + + def test_semver_greater_degrades_to_false_on_unparseable_input(self): + self.assertFalse(core_releaselib.semver_greater("nope", "1.0.0")) + self.assertFalse(core_releaselib.semver_greater("1.0.0", "nope")) + + +class CoreMergeReadinessArmsTest(unittest.TestCase): + """H3: classify_merge_readiness's non-green arms exercised against the + portable module — replacing this module's own copy with a hardcoded + constant previously stayed green, because only the shim's copy was + tested for anything but the required-parameter contract.""" + + SHA = "a" * 40 + OTHER = "b" * 40 + CHECK = "gate" + + def _run(self, status="completed", conclusion="success", head_sha=None, name=None): + return { + "name": self.CHECK if name is None else name, + "status": status, + "conclusion": conclusion, + "head_sha": self.SHA if head_sha is None else head_sha, + } + + def test_missing_when_no_run_matches_the_name(self): + self.assertEqual( + core_releaselib.classify_merge_readiness( + [self._run(name="other")], self.SHA, self.CHECK), + "missing") + + def test_missing_on_empty_check_runs(self): + self.assertEqual( + core_releaselib.classify_merge_readiness([], self.SHA, self.CHECK), + "missing") + + def test_pending_when_not_completed(self): + run = self._run(status="in_progress", conclusion=None) + self.assertEqual( + core_releaselib.classify_merge_readiness([run], self.SHA, self.CHECK), + "pending") + + def test_sha_mismatch_on_a_different_commit(self): + run = self._run(head_sha=self.OTHER) + self.assertEqual( + core_releaselib.classify_merge_readiness([run], self.SHA, self.CHECK), + "sha_mismatch") + + def test_not_successful_on_a_failed_conclusion(self): + run = self._run(conclusion="failure") + self.assertEqual( + core_releaselib.classify_merge_readiness([run], self.SHA, self.CHECK), + "not_successful") + + def test_green_is_still_reachable(self): + self.assertEqual( + core_releaselib.classify_merge_readiness([self._run()], self.SHA, self.CHECK), + "green") + + +class CoreSelectReleaseTargetArmsTest(unittest.TestCase): + """H3: select_release_target's `arity` and `multiple` arms exercised + against the portable module.""" + + TARGETS = ["app", "lib"] + + def test_multiple_when_more_than_one_selected(self): + self.assertEqual( + core_releaselib.select_release_target( + "1.0.0", "2.0.0", targets=self.TARGETS), + "multiple") + + def test_arity_on_a_length_mismatch(self): + self.assertEqual( + core_releaselib.select_release_target("1.0.0", targets=self.TARGETS), + "arity") + + def test_none_when_nothing_selected(self): + self.assertEqual( + core_releaselib.select_release_target("", "", targets=self.TARGETS), + "none") + + def test_the_matching_position_selects_its_target(self): + self.assertEqual( + core_releaselib.select_release_target("", "2.0.0", targets=self.TARGETS), + "lib") + + if __name__ == "__main__": unittest.main() diff --git a/README.md b/README.md index 6c83a2d6..90fb98d6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces. Claude Code plugin Codex plugin Pi Feature Forge preview -version 2.10.8 +version 2.11.0 commands skills agents diff --git a/core/pysrc/_releaselib.py b/core/pysrc/_releaselib.py new file mode 100644 index 00000000..28227337 --- /dev/null +++ b/core/pysrc/_releaselib.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +# codeArbiter — portable release-lane MECHANISM (anchored per-series tag +# selection, semver comparison, publish-state classification, notes-heading +# matching, date consistency) plus the declared-target-file parser. +# +# This module is the PORTABLE half of the release helper split (issue #563). +# It ships from core/pysrc/ into every governance plugin's hooks/ directory +# (tools/sync-core.py, CI-enforced byte-identity) and therefore MUST carry no +# fact about this repository or its CI vocabulary — no plugin name, no path +# under this repository, no check-run name, no tag-namespace mapping. Every +# such fact is DATA, supplied by the caller (a required parameter) or read +# from an operator-declared file via load_targets(). A consuming repository +# supplies its own facts; this module supplies only the mechanism. +# +# Design invariants (mirror the other _*lib helpers): +# - Stdlib only; zero side effects at import (no git, no file I/O, no +# argument parsing at import time). +# - Every mechanism function (semver_key, semver_greater, last_tag_select, +# notes_heading_matches, release_dates_consistent, classify_publish_state, +# select_release_target, classify_merge_readiness, peel_tag) is pure over +# synthetic input and NEVER raises on malformed input — it degrades to the +# safe/refusing answer, per this codebase's "never raise on malformed user +# input" rule for hook-adjacent helpers. +# - The declared-target-file parser (parse_release_targets / load_targets) +# is the deliberate, documented exception to that rule: its input is not +# arbitrary user/session data but an OPERATOR-AUTHORED configuration file +# that a `contents: write` release lane later executes. A malformed +# declaration is a configuration error that MUST surface loudly to the +# operator rather than silently defaulting or partially parsing — so every +# parser-contract violation raises its own distinguishable +# ReleaseTargetsError subclass instead of returning a degraded value or +# letting a bare exception escape from deep inside the parser. +# +# Public API: +# semver_key(value) -> tuple | None +# semver_greater(current, base) -> bool +# last_tag_select(tags, prefix) -> str +# notes_heading_matches(notes_text, tag) -> bool +# release_dates_consistent(changelog_section, tag_message) -> bool +# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, +# manifest_version, release_is_nondraft) -> str +# select_release_target(*confirmations, targets) -> str +# classify_merge_readiness(check_runs, head_sha, check_name) -> str +# peel_tag(ls_remote_text, tag) -> str +# parse_release_targets(text) -> list[dict] +# load_targets(path) -> list[dict] +# +# Declared exceptions (all subclass ReleaseTargetsError): +# AbsentBlockError — no delimiter block present at all +# EmptyBlockError — the delimiter block is present but blank +# MalformedBlockError — bad `[target]` header grammar, a key before the +# first header, or an unparsable line +# UnknownKeyError — a key outside the declared grammar +# DuplicateKeyError — a scalar key repeated within one target block +# DuplicateTargetError — the same `[target]` header declared twice +# InvalidBooleanError — a boolean value that is not exactly true/false +# MultipleBlocksError — more than one delimiter block in the file +# DelimiterInValueError — a value contains the literal closing delimiter, +# which would otherwise truncate the block under a +# naive non-greedy match +# MissingRequiredKeyError — a target block is missing prefix/changelog/payload + +from __future__ import annotations + +import re + + +class ReleaseTargetsError(RuntimeError): + """Base for every declared release-targets-file parse error. Callers that + only care that the declaration was bad, not which rule it broke, can catch + this one type; callers that need to react differently per violation catch + the specific subclass.""" + + +class AbsentBlockError(ReleaseTargetsError): + """No delimiter block is present in the file at all.""" + + +class EmptyBlockError(ReleaseTargetsError): + """The delimiter block is present but contains no declaration content.""" + + +class MalformedBlockError(ReleaseTargetsError): + """A `[target]` header is malformed (empty, or carries a character outside + `[A-Za-z0-9._-]`), a key line appears before the first header, or a line is + neither a header nor a `key: value` pair.""" + + +class UnknownKeyError(ReleaseTargetsError): + """A key outside the declared grammar (e.g. a typo) was used.""" + + +class DuplicateKeyError(ReleaseTargetsError): + """A scalar (non-repeating) key was declared twice within one target block.""" + + +class DuplicateTargetError(ReleaseTargetsError): + """The same `[target]` header was declared more than once.""" + + +class InvalidBooleanError(ReleaseTargetsError): + """A boolean-typed value was neither exactly `true` nor exactly `false`.""" + + +class MultipleBlocksError(ReleaseTargetsError): + """More than one delimiter block was found in the file.""" + + +class DelimiterInValueError(ReleaseTargetsError): + """A declared value contains the literal closing-delimiter text, which + would otherwise silently truncate the block under a naive non-greedy + match rather than being treated as part of the value.""" + + +class MissingRequiredKeyError(ReleaseTargetsError): + """A target block is missing one of the required keys (prefix, changelog, + payload).""" + + +# A `2.9.1`-style series tag is exactly `MAJOR.MINOR.PATCH` — no +# suffix. The anchored form already excludes pre-releases (`2.6.0-beta.1`); +# _PRERELEASE_MARKERS is the explicit, legible second line of defense. +_RELEASE_RE_CACHE = {} + + +def _release_re(prefix): + """The anchored `MAJOR.MINOR.PATCH` matcher for one release series.""" + rx = _RELEASE_RE_CACHE.get(prefix) + if rx is None: + rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") + _RELEASE_RE_CACHE[prefix] = rx + return rx + + +_PRERELEASE_MARKERS = ("-beta", "-rc", "-alpha") + +# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the +# Keep-a-Changelog `## [X.Y.Z] - DATE` bracket form. The capture is the bare +# `X.Y.Z`; the optional leading `v` and the surrounding brackets sit OUTSIDE +# the group, so heading comparison is style-agnostic. Any separator is +# allowed between version and date. Plus the annotated-tag `Released-at:` +# footer. +_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE) +_CHANGELOG_DATE_RE = re.compile( + r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE) +_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})") + +# Full SemVer, including the pre-release and build-metadata tails a release +# tag never carries but a version MANIFEST can. The anchored `_release_re` +# above deliberately rejects those, because it selects a published release +# series; this one parses a version for ORDERING, which is a different +# question and needs the tail. +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + +def semver_key(value): + """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer. + + Non-raising per this module's mechanism-function invariant. Build + metadata is parsed and discarded: SemVer §10 says it is not part of + precedence, so `1.0.0+a` and `1.0.0+b` compare equal. + """ + if not isinstance(value, str): + return None + match = SEMVER.fullmatch(value) + if match is None: + return None + prerelease = match.group(4) + if prerelease is None: + pre_key = None + else: + pre_key = tuple( + (0, int(part)) if part.isdigit() else (1, part) + for part in prerelease.split(".") + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key + + +def semver_greater(current, base): + """True iff `current` is a STRICT SemVer advance over `base`. + + The single definition of "advance" every payload-version gate shares. + Degrades to False when either side is unparseable, which refuses the + gate rather than passing it. Pre-release ordering follows SemVer §11: a + pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric + identifiers compare numerically and rank below alphanumeric ones. + """ + current_key = semver_key(current) + base_key = semver_key(base) + if current_key is None or base_key is None: + return False + if current_key[:3] != base_key[:3]: + return current_key[:3] > base_key[:3] + current_pre, base_pre = current_key[3], base_key[3] + if current_pre is None: + return base_pre is not None + if base_pre is None: + return False + return current_pre > base_pre + + +def _bare_version(tag): + """`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer. + + Lets the heading match compare a tag against a bracket-style changelog + heading without caring about either spelling. + + Anchored on the SemVer at the END rather than by stripping a known + prefix, so a namespaced series' tag (`vMAJOR.MINOR.PATCH`) works + without the prefix being known here — stripping only a LEADING "v" is + right for a bare `v2.9.1` and wrong for any namespaced series, since + `"myapp-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31` + parsed out of the heading.""" + if not isinstance(tag, str): + return tag + text = tag.strip().strip("[]") + match = re.search(r"(\d+\.\d+\.\d+.*)$", text) + return match.group(1) if match else text.lstrip("v") + + +def last_tag_select(tags, prefix): + """Return the highest SemVer tag in `tags` for ONE release series, + excluding pre-releases (`-beta`/`-rc`/`-alpha`). Returns NONE_SENTINEL + when the series has no release tag yet. + + `prefix` selects the series and is REQUIRED — this repository's default + was a repo-specific fact (which series is "the" release) and could not + survive as a module default without smuggling that fact back in. The + caller supplies the prefix for the series it means, typically a value + loaded from a declared row (see `load_targets`). + + This is the single source of `LAST_TAG`, replacing an inline grep + one-liner: bare `git describe --tags` returns the nearest tag by commit- + graph ANCESTRY, which in a multi-series repo is routinely another + series' tag, and silently bases an entire release on the wrong baseline. + + Series isolation is a property of the ANCHORED match rather than a list + of exclusions to maintain: `^v` cannot match `myapp-v0.1.30`, and + `^myapp-v` cannot match `v2.9.1`. A new series therefore cannot leak into + an existing one by being forgotten in an exclusion list.""" + best = None # ((major, minor, patch), original_tag) + if not isinstance(tags, (list, tuple)): + return NONE_SENTINEL + if not isinstance(prefix, str) or not prefix: + return NONE_SENTINEL + matcher = _release_re(prefix) + for t in tags: + if not isinstance(t, str): + continue + m = matcher.match(t) + if not m: + continue + # Tested against the VERSION portion only, after the prefix is + # stripped — never the whole tag. A consumer's own prefix can + # legitimately contain one of these substrings (`web-beta-v`, + # `api-rc-v`); testing the whole tag would reject every one of that + # series' real releases, reading `` as "never released" for a + # series that has releases. + if any(marker in t[len(prefix):] for marker in _PRERELEASE_MARKERS): + continue + ver = tuple(int(g) for g in m.groups()) + if best is None or ver > best[0]: + best = (ver, t) + return best[1] if best else NONE_SENTINEL + + +NONE_SENTINEL = "" + + +def notes_heading_matches(notes_text, tag): + """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z` + or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as + `tag`. A stale notes-file (whose first section is an older version) + returns False, so a release lane cannot publish the wrong changelog + section under the right tag. Missing heading or non-string input -> + False.""" + if not isinstance(notes_text, str) or not isinstance(tag, str): + return False + m = _HEADING_RE.search(notes_text) + if not m: + return False + return m.group(1) == _bare_version(tag) + + +def release_dates_consistent(changelog_section, tag_message): + """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE` + or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in + `tag_message`. Guards against the date being hand-typed inconsistently + across surfaces. Either date absent, or non-string input -> False.""" + if not isinstance(changelog_section, str) or not isinstance(tag_message, str): + return False + cm = _CHANGELOG_DATE_RE.search(changelog_section) + tm = _RELEASED_AT_RE.search(tag_message) + if not cm or not tm: + return False + return cm.group(1) == tm.group(1) + + +def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, + manifest_version, release_is_nondraft): + """Classify a (re)publish attempt so a release lane can resume a + half-finished publish instead of dead-ending on 'tag exists -> STOP'. + Returns one of: + + publish_fresh - no tag yet; the normal path. + already_published - the tag is at HEAD and a non-draft release exists. + resume_publish - tag is at HEAD and its version matches the + manifest, but no non-draft release exists (tag + pushed, release never created) -> finish publish. + abort_mismatch - tag points at a non-HEAD commit, or its version + disagrees with the manifest -> STOP, never overwrite. + + Mismatch OUTRANKS publication state. An existing release used to + short-circuit to `already_published` before the tag was compared to + HEAD, so a resumed publish could silently accept a release whose tag + installs a different snapshot. The tag is what consumers actually fetch; + if it does not name this commit, nothing about the release makes the + state safe. + """ + if not tag_exists: + return "publish_fresh" + if tag_sha != head_sha or tag_version != manifest_version: + return "abort_mismatch" + if release_is_nondraft: + return "already_published" + return "resume_publish" + + +def select_release_target(*confirmations, targets): + """Resolve which single target a release dispatch selected. + `confirmations` are the per-target version inputs, positionally aligned + with `targets`. `targets` is REQUIRED — the register of releasable names + is a repo-specific fact and cannot survive as a module default. Returns + one of: + + - exactly one input was supplied; the matching name from + `targets`. + none - no input was supplied; there is nothing to publish. + multiple - more than one; the dispatch is ambiguous and MUST be + refused. + arity - `confirmations` and `targets` are not the same length. + + Selection is one decision, made once, by a caller that holds no write + token of its own, so a dispatch that supplies more than one confirmation + can never start two `contents: write` publishers. Blank-ish input + (whitespace, non-string) counts as "not selected" so a stray space can + never read as a second target. + + The count is checked against `targets` rather than zipped-to-shortest on + purpose: a caller wired for fewer targets than were actually supplied + would otherwise silently resolve the wrong one. `arity` is not a target + and is meant to match no dispatch case, so a caller's fail-closed default + arm refuses it - and, like every other return here, it is a LABEL rather + than an exception, so a caller's contract of "prints a label and never + raises" holds.""" + def _selected(value): + return isinstance(value, str) and value.strip() != "" + + if not isinstance(targets, (list, tuple)): + return "arity" + if len(confirmations) != len(targets): + return "arity" + selected = [target for target, value in zip(targets, confirmations) + if _selected(value)] + if len(selected) > 1: + return "multiple" + if selected: + return selected[0] + return "none" + + +def classify_merge_readiness(check_runs, head_sha, check_name): + """Classify the merge-readiness evidence for ONE exact commit. `check_runs` + is the `check_runs` array from a commit's check-runs API response. + `check_name` — the single aggregate check that means "every required job + for this commit concluded green" — is REQUIRED: its exact text is a + repo-specific fact (this codebase's own CI vocabulary) and cannot survive + as a module default. Returns one of: + + green - the gate ran for this commit, completed, and succeeded. + missing - no check run by that name is present at all. + pending - present but not `completed` (queued / in_progress / ...). + sha_mismatch - a matching run reports a different `head_sha`. + not_successful - completed with any conclusion other than `success` + (failure, cancelled, skipped, timed_out, neutral, ...). + + A hosted release workflow that only proves it was dispatched from a + protected branch shows how a commit ENTERED that branch, not that + post-merge evidence exists for the exact commit about to be tagged. + + Fail-closed throughout: unparseable input is `missing`, and several runs + share one name only when a re-run is in flight - we cannot tell which + verdict is authoritative, so EVERY matching run must be green.""" + if not isinstance(check_runs, list): + return "missing" + matching = [run for run in check_runs + if isinstance(run, dict) and run.get("name") == check_name] + if not matching: + return "missing" + if any(run.get("head_sha") != head_sha for run in matching): + return "sha_mismatch" + if any(run.get("status") != "completed" for run in matching): + return "pending" + if any(run.get("conclusion") != "success" for run in matching): + return "not_successful" + return "green" + + +def peel_tag(ls_remote_text, tag): + """Resolve the COMMIT a remote tag names, from `git ls-remote --tags` + output. Returns "" when the tag is absent. + + An annotated tag's own object id is not the commit it points at; the + peeled `refs/tags/^{}` line is. A workflow that treats any remote + hit as a resumable publish without comparing the tag to the current + commit can accept a stale tag as a successful rerun and publish for the + wrong commit. Matching is exact on the ref name, so `v2.6.0` is never + resolved from `v2.6.0-beta.1`.""" + if not isinstance(ls_remote_text, str) or not isinstance(tag, str): + return "" + direct = peeled = "" + ref = f"refs/tags/{tag}" + for line in ls_remote_text.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + sha, name = parts + if name == ref + "^{}": + peeled = sha + elif name == ref: + direct = sha + return peeled or direct + + +# --------------------------------------------------------------------------- # +# Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of +# `key: value` lines inside the HTML-comment delimiter convention this +# codebase's path-scope reader (`_scopelib.py`) already uses — reused here +# rather than inventing a second delimiter syntax. +# --------------------------------------------------------------------------- # + +_OPEN_RE = re.compile(r"") +_CLOSE_RE = re.compile(r"") +_HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$") + +# A key not in _LIST_KEYS is scalar: exactly one value per target block, a +# second occurrence of the same key within one block is a DuplicateKeyError. +# List keys repeat by design and preserve declaration order. +_LIST_KEYS = frozenset({"manifest", "artifacts", "pre-tag", "payload-exclude"}) +_BOOLEAN_KEYS = frozenset({"latest-eligible"}) +_REQUIRED_KEYS = ("prefix", "changelog", "payload") + +# Grammar key -> row field name (rows use `_` throughout, the grammar uses +# `-`, matching this codebase's `key: value` / `snake_case` convention split). +_KEY_FIELD = { + "prefix": "prefix", + "changelog": "changelog", + "payload": "payload", + "rebuild": "rebuild", + "provenance-manifest": "provenance_manifest", + "latest-eligible": "latest_eligible", + "manifest": "manifest", + "artifacts": "artifacts", + "pre-tag": "pre_tag", + "payload-exclude": "payload_exclude", +} + + +def _new_row(name): + return { + "target": name, + "prefix": None, + "manifest": [], + "changelog": None, + "payload": None, + "payload_exclude": [], + "rebuild": None, + "artifacts": [], + "provenance_manifest": None, + "pre_tag": [], + "latest_eligible": False, + } + + +def _finish_row(row): + # A parsed key line always assigns a string (`.strip()`-ed at read time), + # so `prefix:` with no value yields `''`, never `None` — an `is None` + # check alone lets that empty declaration pass as "present". Treat a + # blank or whitespace-only value as missing too, so a typo'd required key + # cannot silently become a first-release baseline downstream (`''` fed to + # `last_tag_select` resolves the NONE_SENTINEL). + missing = [key for key in _REQUIRED_KEYS + if (row[_KEY_FIELD[key]] or "").strip() == ""] + if missing: + raise MissingRequiredKeyError( + f"target {row['target']!r} is missing required key(s): " + + ", ".join(missing) + ) + + +def parse_release_targets(text): + """Parse the declared-target-file GRAMMAR from `text` (already-read file + content) into a list of row dicts, one per `[target]` block, each + carrying: target, prefix, manifest (list), changelog, payload, + payload_exclude (list), rebuild, artifacts (list), provenance_manifest, + pre_tag (list), latest_eligible (bool). + + Pure — no file I/O — so it is testable with synthetic input; `load_targets` + is the one function that touches the filesystem. + + Every parser-contract violation raises its own ReleaseTargetsError + subclass; never a silent default, never a partial parse. See the module + docstring for the full list of declared exceptions. + + Cross-platform LF/CRLF editing drift means a value like + `latest-eligible: true\\r` must parse as the boolean `true`, not as an + unrecognised value that would otherwise silently drop a feature — the + exact silent-default failure this module's loud-failure contract + forbids. There is no dedicated CRLF-stripping pass: every line is + `.strip()`-ed on extraction from the block (`raw_line.strip()` below) + and every key/value pair is independently `.strip()`-ed again off the + split — Python's `str.strip()` with no argument removes `\\r` along with + every other whitespace character, so a trailing `\\r` never survives to + a comparison regardless of which layer runs first.""" + if not isinstance(text, str): + # Every declared parser-contract violation raises a ReleaseTargetsError + # subclass so a caller can catch one type (see module docstring); a + # non-string input must not be the one escape hatch that raises a bare + # TypeError instead. There is no block to find in non-text input, so + # this is the same declared answer as an absent block. + raise AbsentBlockError( + "no block found (input is not text)") + normalized = text + + opens = list(_OPEN_RE.finditer(normalized)) + if not opens: + raise AbsentBlockError("no block found") + if len(opens) > 1: + raise MultipleBlocksError( + f"found {len(opens)} opening delimiters; " + "exactly one is allowed") + + after_open = normalized[opens[0].end():] + closes = list(_CLOSE_RE.finditer(after_open)) + if not closes: + raise MalformedBlockError( + " block is never closed") + + # The GENUINE closing delimiter is the first match that sits ALONE on its + # line (only whitespace precedes it since the last newline). A match that + # is preceded by other content on the same line is embedded inside a + # declared value (e.g. `rebuild: echo `) and + # must error rather than silently become the block boundary — otherwise a + # value's embedded delimiter truncates the block and, for a REQUIRED key, + # can silently empty it (`payload: ` would parse + # with `payload == ''`). A close match that occurs entirely AFTER the + # genuine terminator — a legitimate stray mention of the delimiter text in + # prose following the block — is not inspected at all, so it can never be + # misdiagnosed as a value violation. + genuine = None + for m in closes: + line_start = after_open.rfind("\n", 0, m.start()) + 1 + prefix = after_open[line_start:m.start()] + if prefix.strip() == "": + genuine = m + break + raise DelimiterInValueError( + "a declared value contains the literal closing delimiter " + "'', which would truncate the block " + "under a naive parse instead of being treated as part of the value") + + block = after_open[:genuine.start()] + if not block.strip(): + raise EmptyBlockError(" block is empty") + + rows = [] + row = None + seen_keys = None + seen_names = set() + + for raw_line in block.split("\n"): + line = raw_line.strip() + if not line: + continue + + if line.startswith("["): + m = _HEADER_RE.match(line) + if not m: + raise MalformedBlockError( + f"malformed target header: {raw_line!r}") + name = m.group(1) + if name in seen_names: + raise DuplicateTargetError(f"duplicate target block: {name!r}") + seen_names.add(name) + if row is not None: + _finish_row(row) + rows.append(row) + row = _new_row(name) + seen_keys = set() + continue + + if row is None: + raise MalformedBlockError( + f"key line before the first [target] header: {raw_line!r}") + + idx = line.find(":") + if idx == -1: + raise MalformedBlockError( + f"malformed line (expected 'key: value'): {raw_line!r}") + key = line[:idx].strip() + value = line[idx + 1:].strip() + + if key not in _KEY_FIELD: + raise UnknownKeyError( + f"unknown key {key!r} in target {row['target']!r}") + field = _KEY_FIELD[key] + + if key in _LIST_KEYS: + row[field].append(value) + continue + + if key in seen_keys: + raise DuplicateKeyError( + f"duplicate key {key!r} in target {row['target']!r}") + seen_keys.add(key) + + if key in _BOOLEAN_KEYS: + if value == "true": + row[field] = True + elif value == "false": + row[field] = False + else: + raise InvalidBooleanError( + f"key {key!r} in target {row['target']!r} must be " + f"exactly 'true' or 'false', got {value!r}") + else: + row[field] = value + + if row is not None: + _finish_row(row) + rows.append(row) + + return rows + + +def load_targets(path): + """Read `path` and parse it via `parse_release_targets`. The one function + in this module that touches the filesystem — opened with `newline=""` so + a `\\r\\n` line ending survives into the parser exactly as it is on disk, + rather than being silently normalised away by Python's own text-mode + universal-newline translation before this module's own CRLF handling + ever runs. + + An unreadable path (missing file, permission error, a directory, ...) + raises `AbsentBlockError` rather than a bare `OSError` — the same + declared-error contract `parse_release_targets` gives every other + violation, so a `contents: write` caller can catch one exception type + instead of one type for content problems and another for I/O ones. There + is, in the end, no block to find at an unreadable path either.""" + try: + with open(path, encoding="utf-8", newline="") as fh: + text = fh.read() + except OSError as exc: + raise AbsentBlockError( + f"could not read release-targets file {path!r}: {exc}") from exc + return parse_release_targets(text) diff --git a/plugins/ca-codex/hooks/_releaselib.py b/plugins/ca-codex/hooks/_releaselib.py new file mode 100644 index 00000000..28227337 --- /dev/null +++ b/plugins/ca-codex/hooks/_releaselib.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +# codeArbiter — portable release-lane MECHANISM (anchored per-series tag +# selection, semver comparison, publish-state classification, notes-heading +# matching, date consistency) plus the declared-target-file parser. +# +# This module is the PORTABLE half of the release helper split (issue #563). +# It ships from core/pysrc/ into every governance plugin's hooks/ directory +# (tools/sync-core.py, CI-enforced byte-identity) and therefore MUST carry no +# fact about this repository or its CI vocabulary — no plugin name, no path +# under this repository, no check-run name, no tag-namespace mapping. Every +# such fact is DATA, supplied by the caller (a required parameter) or read +# from an operator-declared file via load_targets(). A consuming repository +# supplies its own facts; this module supplies only the mechanism. +# +# Design invariants (mirror the other _*lib helpers): +# - Stdlib only; zero side effects at import (no git, no file I/O, no +# argument parsing at import time). +# - Every mechanism function (semver_key, semver_greater, last_tag_select, +# notes_heading_matches, release_dates_consistent, classify_publish_state, +# select_release_target, classify_merge_readiness, peel_tag) is pure over +# synthetic input and NEVER raises on malformed input — it degrades to the +# safe/refusing answer, per this codebase's "never raise on malformed user +# input" rule for hook-adjacent helpers. +# - The declared-target-file parser (parse_release_targets / load_targets) +# is the deliberate, documented exception to that rule: its input is not +# arbitrary user/session data but an OPERATOR-AUTHORED configuration file +# that a `contents: write` release lane later executes. A malformed +# declaration is a configuration error that MUST surface loudly to the +# operator rather than silently defaulting or partially parsing — so every +# parser-contract violation raises its own distinguishable +# ReleaseTargetsError subclass instead of returning a degraded value or +# letting a bare exception escape from deep inside the parser. +# +# Public API: +# semver_key(value) -> tuple | None +# semver_greater(current, base) -> bool +# last_tag_select(tags, prefix) -> str +# notes_heading_matches(notes_text, tag) -> bool +# release_dates_consistent(changelog_section, tag_message) -> bool +# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, +# manifest_version, release_is_nondraft) -> str +# select_release_target(*confirmations, targets) -> str +# classify_merge_readiness(check_runs, head_sha, check_name) -> str +# peel_tag(ls_remote_text, tag) -> str +# parse_release_targets(text) -> list[dict] +# load_targets(path) -> list[dict] +# +# Declared exceptions (all subclass ReleaseTargetsError): +# AbsentBlockError — no delimiter block present at all +# EmptyBlockError — the delimiter block is present but blank +# MalformedBlockError — bad `[target]` header grammar, a key before the +# first header, or an unparsable line +# UnknownKeyError — a key outside the declared grammar +# DuplicateKeyError — a scalar key repeated within one target block +# DuplicateTargetError — the same `[target]` header declared twice +# InvalidBooleanError — a boolean value that is not exactly true/false +# MultipleBlocksError — more than one delimiter block in the file +# DelimiterInValueError — a value contains the literal closing delimiter, +# which would otherwise truncate the block under a +# naive non-greedy match +# MissingRequiredKeyError — a target block is missing prefix/changelog/payload + +from __future__ import annotations + +import re + + +class ReleaseTargetsError(RuntimeError): + """Base for every declared release-targets-file parse error. Callers that + only care that the declaration was bad, not which rule it broke, can catch + this one type; callers that need to react differently per violation catch + the specific subclass.""" + + +class AbsentBlockError(ReleaseTargetsError): + """No delimiter block is present in the file at all.""" + + +class EmptyBlockError(ReleaseTargetsError): + """The delimiter block is present but contains no declaration content.""" + + +class MalformedBlockError(ReleaseTargetsError): + """A `[target]` header is malformed (empty, or carries a character outside + `[A-Za-z0-9._-]`), a key line appears before the first header, or a line is + neither a header nor a `key: value` pair.""" + + +class UnknownKeyError(ReleaseTargetsError): + """A key outside the declared grammar (e.g. a typo) was used.""" + + +class DuplicateKeyError(ReleaseTargetsError): + """A scalar (non-repeating) key was declared twice within one target block.""" + + +class DuplicateTargetError(ReleaseTargetsError): + """The same `[target]` header was declared more than once.""" + + +class InvalidBooleanError(ReleaseTargetsError): + """A boolean-typed value was neither exactly `true` nor exactly `false`.""" + + +class MultipleBlocksError(ReleaseTargetsError): + """More than one delimiter block was found in the file.""" + + +class DelimiterInValueError(ReleaseTargetsError): + """A declared value contains the literal closing-delimiter text, which + would otherwise silently truncate the block under a naive non-greedy + match rather than being treated as part of the value.""" + + +class MissingRequiredKeyError(ReleaseTargetsError): + """A target block is missing one of the required keys (prefix, changelog, + payload).""" + + +# A `2.9.1`-style series tag is exactly `MAJOR.MINOR.PATCH` — no +# suffix. The anchored form already excludes pre-releases (`2.6.0-beta.1`); +# _PRERELEASE_MARKERS is the explicit, legible second line of defense. +_RELEASE_RE_CACHE = {} + + +def _release_re(prefix): + """The anchored `MAJOR.MINOR.PATCH` matcher for one release series.""" + rx = _RELEASE_RE_CACHE.get(prefix) + if rx is None: + rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") + _RELEASE_RE_CACHE[prefix] = rx + return rx + + +_PRERELEASE_MARKERS = ("-beta", "-rc", "-alpha") + +# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the +# Keep-a-Changelog `## [X.Y.Z] - DATE` bracket form. The capture is the bare +# `X.Y.Z`; the optional leading `v` and the surrounding brackets sit OUTSIDE +# the group, so heading comparison is style-agnostic. Any separator is +# allowed between version and date. Plus the annotated-tag `Released-at:` +# footer. +_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE) +_CHANGELOG_DATE_RE = re.compile( + r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE) +_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})") + +# Full SemVer, including the pre-release and build-metadata tails a release +# tag never carries but a version MANIFEST can. The anchored `_release_re` +# above deliberately rejects those, because it selects a published release +# series; this one parses a version for ORDERING, which is a different +# question and needs the tail. +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + +def semver_key(value): + """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer. + + Non-raising per this module's mechanism-function invariant. Build + metadata is parsed and discarded: SemVer §10 says it is not part of + precedence, so `1.0.0+a` and `1.0.0+b` compare equal. + """ + if not isinstance(value, str): + return None + match = SEMVER.fullmatch(value) + if match is None: + return None + prerelease = match.group(4) + if prerelease is None: + pre_key = None + else: + pre_key = tuple( + (0, int(part)) if part.isdigit() else (1, part) + for part in prerelease.split(".") + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key + + +def semver_greater(current, base): + """True iff `current` is a STRICT SemVer advance over `base`. + + The single definition of "advance" every payload-version gate shares. + Degrades to False when either side is unparseable, which refuses the + gate rather than passing it. Pre-release ordering follows SemVer §11: a + pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric + identifiers compare numerically and rank below alphanumeric ones. + """ + current_key = semver_key(current) + base_key = semver_key(base) + if current_key is None or base_key is None: + return False + if current_key[:3] != base_key[:3]: + return current_key[:3] > base_key[:3] + current_pre, base_pre = current_key[3], base_key[3] + if current_pre is None: + return base_pre is not None + if base_pre is None: + return False + return current_pre > base_pre + + +def _bare_version(tag): + """`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer. + + Lets the heading match compare a tag against a bracket-style changelog + heading without caring about either spelling. + + Anchored on the SemVer at the END rather than by stripping a known + prefix, so a namespaced series' tag (`vMAJOR.MINOR.PATCH`) works + without the prefix being known here — stripping only a LEADING "v" is + right for a bare `v2.9.1` and wrong for any namespaced series, since + `"myapp-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31` + parsed out of the heading.""" + if not isinstance(tag, str): + return tag + text = tag.strip().strip("[]") + match = re.search(r"(\d+\.\d+\.\d+.*)$", text) + return match.group(1) if match else text.lstrip("v") + + +def last_tag_select(tags, prefix): + """Return the highest SemVer tag in `tags` for ONE release series, + excluding pre-releases (`-beta`/`-rc`/`-alpha`). Returns NONE_SENTINEL + when the series has no release tag yet. + + `prefix` selects the series and is REQUIRED — this repository's default + was a repo-specific fact (which series is "the" release) and could not + survive as a module default without smuggling that fact back in. The + caller supplies the prefix for the series it means, typically a value + loaded from a declared row (see `load_targets`). + + This is the single source of `LAST_TAG`, replacing an inline grep + one-liner: bare `git describe --tags` returns the nearest tag by commit- + graph ANCESTRY, which in a multi-series repo is routinely another + series' tag, and silently bases an entire release on the wrong baseline. + + Series isolation is a property of the ANCHORED match rather than a list + of exclusions to maintain: `^v` cannot match `myapp-v0.1.30`, and + `^myapp-v` cannot match `v2.9.1`. A new series therefore cannot leak into + an existing one by being forgotten in an exclusion list.""" + best = None # ((major, minor, patch), original_tag) + if not isinstance(tags, (list, tuple)): + return NONE_SENTINEL + if not isinstance(prefix, str) or not prefix: + return NONE_SENTINEL + matcher = _release_re(prefix) + for t in tags: + if not isinstance(t, str): + continue + m = matcher.match(t) + if not m: + continue + # Tested against the VERSION portion only, after the prefix is + # stripped — never the whole tag. A consumer's own prefix can + # legitimately contain one of these substrings (`web-beta-v`, + # `api-rc-v`); testing the whole tag would reject every one of that + # series' real releases, reading `` as "never released" for a + # series that has releases. + if any(marker in t[len(prefix):] for marker in _PRERELEASE_MARKERS): + continue + ver = tuple(int(g) for g in m.groups()) + if best is None or ver > best[0]: + best = (ver, t) + return best[1] if best else NONE_SENTINEL + + +NONE_SENTINEL = "" + + +def notes_heading_matches(notes_text, tag): + """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z` + or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as + `tag`. A stale notes-file (whose first section is an older version) + returns False, so a release lane cannot publish the wrong changelog + section under the right tag. Missing heading or non-string input -> + False.""" + if not isinstance(notes_text, str) or not isinstance(tag, str): + return False + m = _HEADING_RE.search(notes_text) + if not m: + return False + return m.group(1) == _bare_version(tag) + + +def release_dates_consistent(changelog_section, tag_message): + """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE` + or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in + `tag_message`. Guards against the date being hand-typed inconsistently + across surfaces. Either date absent, or non-string input -> False.""" + if not isinstance(changelog_section, str) or not isinstance(tag_message, str): + return False + cm = _CHANGELOG_DATE_RE.search(changelog_section) + tm = _RELEASED_AT_RE.search(tag_message) + if not cm or not tm: + return False + return cm.group(1) == tm.group(1) + + +def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, + manifest_version, release_is_nondraft): + """Classify a (re)publish attempt so a release lane can resume a + half-finished publish instead of dead-ending on 'tag exists -> STOP'. + Returns one of: + + publish_fresh - no tag yet; the normal path. + already_published - the tag is at HEAD and a non-draft release exists. + resume_publish - tag is at HEAD and its version matches the + manifest, but no non-draft release exists (tag + pushed, release never created) -> finish publish. + abort_mismatch - tag points at a non-HEAD commit, or its version + disagrees with the manifest -> STOP, never overwrite. + + Mismatch OUTRANKS publication state. An existing release used to + short-circuit to `already_published` before the tag was compared to + HEAD, so a resumed publish could silently accept a release whose tag + installs a different snapshot. The tag is what consumers actually fetch; + if it does not name this commit, nothing about the release makes the + state safe. + """ + if not tag_exists: + return "publish_fresh" + if tag_sha != head_sha or tag_version != manifest_version: + return "abort_mismatch" + if release_is_nondraft: + return "already_published" + return "resume_publish" + + +def select_release_target(*confirmations, targets): + """Resolve which single target a release dispatch selected. + `confirmations` are the per-target version inputs, positionally aligned + with `targets`. `targets` is REQUIRED — the register of releasable names + is a repo-specific fact and cannot survive as a module default. Returns + one of: + + - exactly one input was supplied; the matching name from + `targets`. + none - no input was supplied; there is nothing to publish. + multiple - more than one; the dispatch is ambiguous and MUST be + refused. + arity - `confirmations` and `targets` are not the same length. + + Selection is one decision, made once, by a caller that holds no write + token of its own, so a dispatch that supplies more than one confirmation + can never start two `contents: write` publishers. Blank-ish input + (whitespace, non-string) counts as "not selected" so a stray space can + never read as a second target. + + The count is checked against `targets` rather than zipped-to-shortest on + purpose: a caller wired for fewer targets than were actually supplied + would otherwise silently resolve the wrong one. `arity` is not a target + and is meant to match no dispatch case, so a caller's fail-closed default + arm refuses it - and, like every other return here, it is a LABEL rather + than an exception, so a caller's contract of "prints a label and never + raises" holds.""" + def _selected(value): + return isinstance(value, str) and value.strip() != "" + + if not isinstance(targets, (list, tuple)): + return "arity" + if len(confirmations) != len(targets): + return "arity" + selected = [target for target, value in zip(targets, confirmations) + if _selected(value)] + if len(selected) > 1: + return "multiple" + if selected: + return selected[0] + return "none" + + +def classify_merge_readiness(check_runs, head_sha, check_name): + """Classify the merge-readiness evidence for ONE exact commit. `check_runs` + is the `check_runs` array from a commit's check-runs API response. + `check_name` — the single aggregate check that means "every required job + for this commit concluded green" — is REQUIRED: its exact text is a + repo-specific fact (this codebase's own CI vocabulary) and cannot survive + as a module default. Returns one of: + + green - the gate ran for this commit, completed, and succeeded. + missing - no check run by that name is present at all. + pending - present but not `completed` (queued / in_progress / ...). + sha_mismatch - a matching run reports a different `head_sha`. + not_successful - completed with any conclusion other than `success` + (failure, cancelled, skipped, timed_out, neutral, ...). + + A hosted release workflow that only proves it was dispatched from a + protected branch shows how a commit ENTERED that branch, not that + post-merge evidence exists for the exact commit about to be tagged. + + Fail-closed throughout: unparseable input is `missing`, and several runs + share one name only when a re-run is in flight - we cannot tell which + verdict is authoritative, so EVERY matching run must be green.""" + if not isinstance(check_runs, list): + return "missing" + matching = [run for run in check_runs + if isinstance(run, dict) and run.get("name") == check_name] + if not matching: + return "missing" + if any(run.get("head_sha") != head_sha for run in matching): + return "sha_mismatch" + if any(run.get("status") != "completed" for run in matching): + return "pending" + if any(run.get("conclusion") != "success" for run in matching): + return "not_successful" + return "green" + + +def peel_tag(ls_remote_text, tag): + """Resolve the COMMIT a remote tag names, from `git ls-remote --tags` + output. Returns "" when the tag is absent. + + An annotated tag's own object id is not the commit it points at; the + peeled `refs/tags/^{}` line is. A workflow that treats any remote + hit as a resumable publish without comparing the tag to the current + commit can accept a stale tag as a successful rerun and publish for the + wrong commit. Matching is exact on the ref name, so `v2.6.0` is never + resolved from `v2.6.0-beta.1`.""" + if not isinstance(ls_remote_text, str) or not isinstance(tag, str): + return "" + direct = peeled = "" + ref = f"refs/tags/{tag}" + for line in ls_remote_text.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + sha, name = parts + if name == ref + "^{}": + peeled = sha + elif name == ref: + direct = sha + return peeled or direct + + +# --------------------------------------------------------------------------- # +# Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of +# `key: value` lines inside the HTML-comment delimiter convention this +# codebase's path-scope reader (`_scopelib.py`) already uses — reused here +# rather than inventing a second delimiter syntax. +# --------------------------------------------------------------------------- # + +_OPEN_RE = re.compile(r"") +_CLOSE_RE = re.compile(r"") +_HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$") + +# A key not in _LIST_KEYS is scalar: exactly one value per target block, a +# second occurrence of the same key within one block is a DuplicateKeyError. +# List keys repeat by design and preserve declaration order. +_LIST_KEYS = frozenset({"manifest", "artifacts", "pre-tag", "payload-exclude"}) +_BOOLEAN_KEYS = frozenset({"latest-eligible"}) +_REQUIRED_KEYS = ("prefix", "changelog", "payload") + +# Grammar key -> row field name (rows use `_` throughout, the grammar uses +# `-`, matching this codebase's `key: value` / `snake_case` convention split). +_KEY_FIELD = { + "prefix": "prefix", + "changelog": "changelog", + "payload": "payload", + "rebuild": "rebuild", + "provenance-manifest": "provenance_manifest", + "latest-eligible": "latest_eligible", + "manifest": "manifest", + "artifacts": "artifacts", + "pre-tag": "pre_tag", + "payload-exclude": "payload_exclude", +} + + +def _new_row(name): + return { + "target": name, + "prefix": None, + "manifest": [], + "changelog": None, + "payload": None, + "payload_exclude": [], + "rebuild": None, + "artifacts": [], + "provenance_manifest": None, + "pre_tag": [], + "latest_eligible": False, + } + + +def _finish_row(row): + # A parsed key line always assigns a string (`.strip()`-ed at read time), + # so `prefix:` with no value yields `''`, never `None` — an `is None` + # check alone lets that empty declaration pass as "present". Treat a + # blank or whitespace-only value as missing too, so a typo'd required key + # cannot silently become a first-release baseline downstream (`''` fed to + # `last_tag_select` resolves the NONE_SENTINEL). + missing = [key for key in _REQUIRED_KEYS + if (row[_KEY_FIELD[key]] or "").strip() == ""] + if missing: + raise MissingRequiredKeyError( + f"target {row['target']!r} is missing required key(s): " + + ", ".join(missing) + ) + + +def parse_release_targets(text): + """Parse the declared-target-file GRAMMAR from `text` (already-read file + content) into a list of row dicts, one per `[target]` block, each + carrying: target, prefix, manifest (list), changelog, payload, + payload_exclude (list), rebuild, artifacts (list), provenance_manifest, + pre_tag (list), latest_eligible (bool). + + Pure — no file I/O — so it is testable with synthetic input; `load_targets` + is the one function that touches the filesystem. + + Every parser-contract violation raises its own ReleaseTargetsError + subclass; never a silent default, never a partial parse. See the module + docstring for the full list of declared exceptions. + + Cross-platform LF/CRLF editing drift means a value like + `latest-eligible: true\\r` must parse as the boolean `true`, not as an + unrecognised value that would otherwise silently drop a feature — the + exact silent-default failure this module's loud-failure contract + forbids. There is no dedicated CRLF-stripping pass: every line is + `.strip()`-ed on extraction from the block (`raw_line.strip()` below) + and every key/value pair is independently `.strip()`-ed again off the + split — Python's `str.strip()` with no argument removes `\\r` along with + every other whitespace character, so a trailing `\\r` never survives to + a comparison regardless of which layer runs first.""" + if not isinstance(text, str): + # Every declared parser-contract violation raises a ReleaseTargetsError + # subclass so a caller can catch one type (see module docstring); a + # non-string input must not be the one escape hatch that raises a bare + # TypeError instead. There is no block to find in non-text input, so + # this is the same declared answer as an absent block. + raise AbsentBlockError( + "no block found (input is not text)") + normalized = text + + opens = list(_OPEN_RE.finditer(normalized)) + if not opens: + raise AbsentBlockError("no block found") + if len(opens) > 1: + raise MultipleBlocksError( + f"found {len(opens)} opening delimiters; " + "exactly one is allowed") + + after_open = normalized[opens[0].end():] + closes = list(_CLOSE_RE.finditer(after_open)) + if not closes: + raise MalformedBlockError( + " block is never closed") + + # The GENUINE closing delimiter is the first match that sits ALONE on its + # line (only whitespace precedes it since the last newline). A match that + # is preceded by other content on the same line is embedded inside a + # declared value (e.g. `rebuild: echo `) and + # must error rather than silently become the block boundary — otherwise a + # value's embedded delimiter truncates the block and, for a REQUIRED key, + # can silently empty it (`payload: ` would parse + # with `payload == ''`). A close match that occurs entirely AFTER the + # genuine terminator — a legitimate stray mention of the delimiter text in + # prose following the block — is not inspected at all, so it can never be + # misdiagnosed as a value violation. + genuine = None + for m in closes: + line_start = after_open.rfind("\n", 0, m.start()) + 1 + prefix = after_open[line_start:m.start()] + if prefix.strip() == "": + genuine = m + break + raise DelimiterInValueError( + "a declared value contains the literal closing delimiter " + "'', which would truncate the block " + "under a naive parse instead of being treated as part of the value") + + block = after_open[:genuine.start()] + if not block.strip(): + raise EmptyBlockError(" block is empty") + + rows = [] + row = None + seen_keys = None + seen_names = set() + + for raw_line in block.split("\n"): + line = raw_line.strip() + if not line: + continue + + if line.startswith("["): + m = _HEADER_RE.match(line) + if not m: + raise MalformedBlockError( + f"malformed target header: {raw_line!r}") + name = m.group(1) + if name in seen_names: + raise DuplicateTargetError(f"duplicate target block: {name!r}") + seen_names.add(name) + if row is not None: + _finish_row(row) + rows.append(row) + row = _new_row(name) + seen_keys = set() + continue + + if row is None: + raise MalformedBlockError( + f"key line before the first [target] header: {raw_line!r}") + + idx = line.find(":") + if idx == -1: + raise MalformedBlockError( + f"malformed line (expected 'key: value'): {raw_line!r}") + key = line[:idx].strip() + value = line[idx + 1:].strip() + + if key not in _KEY_FIELD: + raise UnknownKeyError( + f"unknown key {key!r} in target {row['target']!r}") + field = _KEY_FIELD[key] + + if key in _LIST_KEYS: + row[field].append(value) + continue + + if key in seen_keys: + raise DuplicateKeyError( + f"duplicate key {key!r} in target {row['target']!r}") + seen_keys.add(key) + + if key in _BOOLEAN_KEYS: + if value == "true": + row[field] = True + elif value == "false": + row[field] = False + else: + raise InvalidBooleanError( + f"key {key!r} in target {row['target']!r} must be " + f"exactly 'true' or 'false', got {value!r}") + else: + row[field] = value + + if row is not None: + _finish_row(row) + rows.append(row) + + return rows + + +def load_targets(path): + """Read `path` and parse it via `parse_release_targets`. The one function + in this module that touches the filesystem — opened with `newline=""` so + a `\\r\\n` line ending survives into the parser exactly as it is on disk, + rather than being silently normalised away by Python's own text-mode + universal-newline translation before this module's own CRLF handling + ever runs. + + An unreadable path (missing file, permission error, a directory, ...) + raises `AbsentBlockError` rather than a bare `OSError` — the same + declared-error contract `parse_release_targets` gives every other + violation, so a `contents: write` caller can catch one exception type + instead of one type for content problems and another for I/O ones. There + is, in the end, no block to find at an unreadable path either.""" + try: + with open(path, encoding="utf-8", newline="") as fh: + text = fh.read() + except OSError as exc: + raise AbsentBlockError( + f"could not read release-targets file {path!r}: {exc}") from exc + return parse_release_targets(text) diff --git a/plugins/ca-pi/hooks/_releaselib.py b/plugins/ca-pi/hooks/_releaselib.py new file mode 100644 index 00000000..28227337 --- /dev/null +++ b/plugins/ca-pi/hooks/_releaselib.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +# codeArbiter — portable release-lane MECHANISM (anchored per-series tag +# selection, semver comparison, publish-state classification, notes-heading +# matching, date consistency) plus the declared-target-file parser. +# +# This module is the PORTABLE half of the release helper split (issue #563). +# It ships from core/pysrc/ into every governance plugin's hooks/ directory +# (tools/sync-core.py, CI-enforced byte-identity) and therefore MUST carry no +# fact about this repository or its CI vocabulary — no plugin name, no path +# under this repository, no check-run name, no tag-namespace mapping. Every +# such fact is DATA, supplied by the caller (a required parameter) or read +# from an operator-declared file via load_targets(). A consuming repository +# supplies its own facts; this module supplies only the mechanism. +# +# Design invariants (mirror the other _*lib helpers): +# - Stdlib only; zero side effects at import (no git, no file I/O, no +# argument parsing at import time). +# - Every mechanism function (semver_key, semver_greater, last_tag_select, +# notes_heading_matches, release_dates_consistent, classify_publish_state, +# select_release_target, classify_merge_readiness, peel_tag) is pure over +# synthetic input and NEVER raises on malformed input — it degrades to the +# safe/refusing answer, per this codebase's "never raise on malformed user +# input" rule for hook-adjacent helpers. +# - The declared-target-file parser (parse_release_targets / load_targets) +# is the deliberate, documented exception to that rule: its input is not +# arbitrary user/session data but an OPERATOR-AUTHORED configuration file +# that a `contents: write` release lane later executes. A malformed +# declaration is a configuration error that MUST surface loudly to the +# operator rather than silently defaulting or partially parsing — so every +# parser-contract violation raises its own distinguishable +# ReleaseTargetsError subclass instead of returning a degraded value or +# letting a bare exception escape from deep inside the parser. +# +# Public API: +# semver_key(value) -> tuple | None +# semver_greater(current, base) -> bool +# last_tag_select(tags, prefix) -> str +# notes_heading_matches(notes_text, tag) -> bool +# release_dates_consistent(changelog_section, tag_message) -> bool +# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, +# manifest_version, release_is_nondraft) -> str +# select_release_target(*confirmations, targets) -> str +# classify_merge_readiness(check_runs, head_sha, check_name) -> str +# peel_tag(ls_remote_text, tag) -> str +# parse_release_targets(text) -> list[dict] +# load_targets(path) -> list[dict] +# +# Declared exceptions (all subclass ReleaseTargetsError): +# AbsentBlockError — no delimiter block present at all +# EmptyBlockError — the delimiter block is present but blank +# MalformedBlockError — bad `[target]` header grammar, a key before the +# first header, or an unparsable line +# UnknownKeyError — a key outside the declared grammar +# DuplicateKeyError — a scalar key repeated within one target block +# DuplicateTargetError — the same `[target]` header declared twice +# InvalidBooleanError — a boolean value that is not exactly true/false +# MultipleBlocksError — more than one delimiter block in the file +# DelimiterInValueError — a value contains the literal closing delimiter, +# which would otherwise truncate the block under a +# naive non-greedy match +# MissingRequiredKeyError — a target block is missing prefix/changelog/payload + +from __future__ import annotations + +import re + + +class ReleaseTargetsError(RuntimeError): + """Base for every declared release-targets-file parse error. Callers that + only care that the declaration was bad, not which rule it broke, can catch + this one type; callers that need to react differently per violation catch + the specific subclass.""" + + +class AbsentBlockError(ReleaseTargetsError): + """No delimiter block is present in the file at all.""" + + +class EmptyBlockError(ReleaseTargetsError): + """The delimiter block is present but contains no declaration content.""" + + +class MalformedBlockError(ReleaseTargetsError): + """A `[target]` header is malformed (empty, or carries a character outside + `[A-Za-z0-9._-]`), a key line appears before the first header, or a line is + neither a header nor a `key: value` pair.""" + + +class UnknownKeyError(ReleaseTargetsError): + """A key outside the declared grammar (e.g. a typo) was used.""" + + +class DuplicateKeyError(ReleaseTargetsError): + """A scalar (non-repeating) key was declared twice within one target block.""" + + +class DuplicateTargetError(ReleaseTargetsError): + """The same `[target]` header was declared more than once.""" + + +class InvalidBooleanError(ReleaseTargetsError): + """A boolean-typed value was neither exactly `true` nor exactly `false`.""" + + +class MultipleBlocksError(ReleaseTargetsError): + """More than one delimiter block was found in the file.""" + + +class DelimiterInValueError(ReleaseTargetsError): + """A declared value contains the literal closing-delimiter text, which + would otherwise silently truncate the block under a naive non-greedy + match rather than being treated as part of the value.""" + + +class MissingRequiredKeyError(ReleaseTargetsError): + """A target block is missing one of the required keys (prefix, changelog, + payload).""" + + +# A `2.9.1`-style series tag is exactly `MAJOR.MINOR.PATCH` — no +# suffix. The anchored form already excludes pre-releases (`2.6.0-beta.1`); +# _PRERELEASE_MARKERS is the explicit, legible second line of defense. +_RELEASE_RE_CACHE = {} + + +def _release_re(prefix): + """The anchored `MAJOR.MINOR.PATCH` matcher for one release series.""" + rx = _RELEASE_RE_CACHE.get(prefix) + if rx is None: + rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") + _RELEASE_RE_CACHE[prefix] = rx + return rx + + +_PRERELEASE_MARKERS = ("-beta", "-rc", "-alpha") + +# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the +# Keep-a-Changelog `## [X.Y.Z] - DATE` bracket form. The capture is the bare +# `X.Y.Z`; the optional leading `v` and the surrounding brackets sit OUTSIDE +# the group, so heading comparison is style-agnostic. Any separator is +# allowed between version and date. Plus the annotated-tag `Released-at:` +# footer. +_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE) +_CHANGELOG_DATE_RE = re.compile( + r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE) +_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})") + +# Full SemVer, including the pre-release and build-metadata tails a release +# tag never carries but a version MANIFEST can. The anchored `_release_re` +# above deliberately rejects those, because it selects a published release +# series; this one parses a version for ORDERING, which is a different +# question and needs the tail. +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + +def semver_key(value): + """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer. + + Non-raising per this module's mechanism-function invariant. Build + metadata is parsed and discarded: SemVer §10 says it is not part of + precedence, so `1.0.0+a` and `1.0.0+b` compare equal. + """ + if not isinstance(value, str): + return None + match = SEMVER.fullmatch(value) + if match is None: + return None + prerelease = match.group(4) + if prerelease is None: + pre_key = None + else: + pre_key = tuple( + (0, int(part)) if part.isdigit() else (1, part) + for part in prerelease.split(".") + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key + + +def semver_greater(current, base): + """True iff `current` is a STRICT SemVer advance over `base`. + + The single definition of "advance" every payload-version gate shares. + Degrades to False when either side is unparseable, which refuses the + gate rather than passing it. Pre-release ordering follows SemVer §11: a + pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric + identifiers compare numerically and rank below alphanumeric ones. + """ + current_key = semver_key(current) + base_key = semver_key(base) + if current_key is None or base_key is None: + return False + if current_key[:3] != base_key[:3]: + return current_key[:3] > base_key[:3] + current_pre, base_pre = current_key[3], base_key[3] + if current_pre is None: + return base_pre is not None + if base_pre is None: + return False + return current_pre > base_pre + + +def _bare_version(tag): + """`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer. + + Lets the heading match compare a tag against a bracket-style changelog + heading without caring about either spelling. + + Anchored on the SemVer at the END rather than by stripping a known + prefix, so a namespaced series' tag (`vMAJOR.MINOR.PATCH`) works + without the prefix being known here — stripping only a LEADING "v" is + right for a bare `v2.9.1` and wrong for any namespaced series, since + `"myapp-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31` + parsed out of the heading.""" + if not isinstance(tag, str): + return tag + text = tag.strip().strip("[]") + match = re.search(r"(\d+\.\d+\.\d+.*)$", text) + return match.group(1) if match else text.lstrip("v") + + +def last_tag_select(tags, prefix): + """Return the highest SemVer tag in `tags` for ONE release series, + excluding pre-releases (`-beta`/`-rc`/`-alpha`). Returns NONE_SENTINEL + when the series has no release tag yet. + + `prefix` selects the series and is REQUIRED — this repository's default + was a repo-specific fact (which series is "the" release) and could not + survive as a module default without smuggling that fact back in. The + caller supplies the prefix for the series it means, typically a value + loaded from a declared row (see `load_targets`). + + This is the single source of `LAST_TAG`, replacing an inline grep + one-liner: bare `git describe --tags` returns the nearest tag by commit- + graph ANCESTRY, which in a multi-series repo is routinely another + series' tag, and silently bases an entire release on the wrong baseline. + + Series isolation is a property of the ANCHORED match rather than a list + of exclusions to maintain: `^v` cannot match `myapp-v0.1.30`, and + `^myapp-v` cannot match `v2.9.1`. A new series therefore cannot leak into + an existing one by being forgotten in an exclusion list.""" + best = None # ((major, minor, patch), original_tag) + if not isinstance(tags, (list, tuple)): + return NONE_SENTINEL + if not isinstance(prefix, str) or not prefix: + return NONE_SENTINEL + matcher = _release_re(prefix) + for t in tags: + if not isinstance(t, str): + continue + m = matcher.match(t) + if not m: + continue + # Tested against the VERSION portion only, after the prefix is + # stripped — never the whole tag. A consumer's own prefix can + # legitimately contain one of these substrings (`web-beta-v`, + # `api-rc-v`); testing the whole tag would reject every one of that + # series' real releases, reading `` as "never released" for a + # series that has releases. + if any(marker in t[len(prefix):] for marker in _PRERELEASE_MARKERS): + continue + ver = tuple(int(g) for g in m.groups()) + if best is None or ver > best[0]: + best = (ver, t) + return best[1] if best else NONE_SENTINEL + + +NONE_SENTINEL = "" + + +def notes_heading_matches(notes_text, tag): + """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z` + or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as + `tag`. A stale notes-file (whose first section is an older version) + returns False, so a release lane cannot publish the wrong changelog + section under the right tag. Missing heading or non-string input -> + False.""" + if not isinstance(notes_text, str) or not isinstance(tag, str): + return False + m = _HEADING_RE.search(notes_text) + if not m: + return False + return m.group(1) == _bare_version(tag) + + +def release_dates_consistent(changelog_section, tag_message): + """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE` + or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in + `tag_message`. Guards against the date being hand-typed inconsistently + across surfaces. Either date absent, or non-string input -> False.""" + if not isinstance(changelog_section, str) or not isinstance(tag_message, str): + return False + cm = _CHANGELOG_DATE_RE.search(changelog_section) + tm = _RELEASED_AT_RE.search(tag_message) + if not cm or not tm: + return False + return cm.group(1) == tm.group(1) + + +def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, + manifest_version, release_is_nondraft): + """Classify a (re)publish attempt so a release lane can resume a + half-finished publish instead of dead-ending on 'tag exists -> STOP'. + Returns one of: + + publish_fresh - no tag yet; the normal path. + already_published - the tag is at HEAD and a non-draft release exists. + resume_publish - tag is at HEAD and its version matches the + manifest, but no non-draft release exists (tag + pushed, release never created) -> finish publish. + abort_mismatch - tag points at a non-HEAD commit, or its version + disagrees with the manifest -> STOP, never overwrite. + + Mismatch OUTRANKS publication state. An existing release used to + short-circuit to `already_published` before the tag was compared to + HEAD, so a resumed publish could silently accept a release whose tag + installs a different snapshot. The tag is what consumers actually fetch; + if it does not name this commit, nothing about the release makes the + state safe. + """ + if not tag_exists: + return "publish_fresh" + if tag_sha != head_sha or tag_version != manifest_version: + return "abort_mismatch" + if release_is_nondraft: + return "already_published" + return "resume_publish" + + +def select_release_target(*confirmations, targets): + """Resolve which single target a release dispatch selected. + `confirmations` are the per-target version inputs, positionally aligned + with `targets`. `targets` is REQUIRED — the register of releasable names + is a repo-specific fact and cannot survive as a module default. Returns + one of: + + - exactly one input was supplied; the matching name from + `targets`. + none - no input was supplied; there is nothing to publish. + multiple - more than one; the dispatch is ambiguous and MUST be + refused. + arity - `confirmations` and `targets` are not the same length. + + Selection is one decision, made once, by a caller that holds no write + token of its own, so a dispatch that supplies more than one confirmation + can never start two `contents: write` publishers. Blank-ish input + (whitespace, non-string) counts as "not selected" so a stray space can + never read as a second target. + + The count is checked against `targets` rather than zipped-to-shortest on + purpose: a caller wired for fewer targets than were actually supplied + would otherwise silently resolve the wrong one. `arity` is not a target + and is meant to match no dispatch case, so a caller's fail-closed default + arm refuses it - and, like every other return here, it is a LABEL rather + than an exception, so a caller's contract of "prints a label and never + raises" holds.""" + def _selected(value): + return isinstance(value, str) and value.strip() != "" + + if not isinstance(targets, (list, tuple)): + return "arity" + if len(confirmations) != len(targets): + return "arity" + selected = [target for target, value in zip(targets, confirmations) + if _selected(value)] + if len(selected) > 1: + return "multiple" + if selected: + return selected[0] + return "none" + + +def classify_merge_readiness(check_runs, head_sha, check_name): + """Classify the merge-readiness evidence for ONE exact commit. `check_runs` + is the `check_runs` array from a commit's check-runs API response. + `check_name` — the single aggregate check that means "every required job + for this commit concluded green" — is REQUIRED: its exact text is a + repo-specific fact (this codebase's own CI vocabulary) and cannot survive + as a module default. Returns one of: + + green - the gate ran for this commit, completed, and succeeded. + missing - no check run by that name is present at all. + pending - present but not `completed` (queued / in_progress / ...). + sha_mismatch - a matching run reports a different `head_sha`. + not_successful - completed with any conclusion other than `success` + (failure, cancelled, skipped, timed_out, neutral, ...). + + A hosted release workflow that only proves it was dispatched from a + protected branch shows how a commit ENTERED that branch, not that + post-merge evidence exists for the exact commit about to be tagged. + + Fail-closed throughout: unparseable input is `missing`, and several runs + share one name only when a re-run is in flight - we cannot tell which + verdict is authoritative, so EVERY matching run must be green.""" + if not isinstance(check_runs, list): + return "missing" + matching = [run for run in check_runs + if isinstance(run, dict) and run.get("name") == check_name] + if not matching: + return "missing" + if any(run.get("head_sha") != head_sha for run in matching): + return "sha_mismatch" + if any(run.get("status") != "completed" for run in matching): + return "pending" + if any(run.get("conclusion") != "success" for run in matching): + return "not_successful" + return "green" + + +def peel_tag(ls_remote_text, tag): + """Resolve the COMMIT a remote tag names, from `git ls-remote --tags` + output. Returns "" when the tag is absent. + + An annotated tag's own object id is not the commit it points at; the + peeled `refs/tags/^{}` line is. A workflow that treats any remote + hit as a resumable publish without comparing the tag to the current + commit can accept a stale tag as a successful rerun and publish for the + wrong commit. Matching is exact on the ref name, so `v2.6.0` is never + resolved from `v2.6.0-beta.1`.""" + if not isinstance(ls_remote_text, str) or not isinstance(tag, str): + return "" + direct = peeled = "" + ref = f"refs/tags/{tag}" + for line in ls_remote_text.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + sha, name = parts + if name == ref + "^{}": + peeled = sha + elif name == ref: + direct = sha + return peeled or direct + + +# --------------------------------------------------------------------------- # +# Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of +# `key: value` lines inside the HTML-comment delimiter convention this +# codebase's path-scope reader (`_scopelib.py`) already uses — reused here +# rather than inventing a second delimiter syntax. +# --------------------------------------------------------------------------- # + +_OPEN_RE = re.compile(r"") +_CLOSE_RE = re.compile(r"") +_HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$") + +# A key not in _LIST_KEYS is scalar: exactly one value per target block, a +# second occurrence of the same key within one block is a DuplicateKeyError. +# List keys repeat by design and preserve declaration order. +_LIST_KEYS = frozenset({"manifest", "artifacts", "pre-tag", "payload-exclude"}) +_BOOLEAN_KEYS = frozenset({"latest-eligible"}) +_REQUIRED_KEYS = ("prefix", "changelog", "payload") + +# Grammar key -> row field name (rows use `_` throughout, the grammar uses +# `-`, matching this codebase's `key: value` / `snake_case` convention split). +_KEY_FIELD = { + "prefix": "prefix", + "changelog": "changelog", + "payload": "payload", + "rebuild": "rebuild", + "provenance-manifest": "provenance_manifest", + "latest-eligible": "latest_eligible", + "manifest": "manifest", + "artifacts": "artifacts", + "pre-tag": "pre_tag", + "payload-exclude": "payload_exclude", +} + + +def _new_row(name): + return { + "target": name, + "prefix": None, + "manifest": [], + "changelog": None, + "payload": None, + "payload_exclude": [], + "rebuild": None, + "artifacts": [], + "provenance_manifest": None, + "pre_tag": [], + "latest_eligible": False, + } + + +def _finish_row(row): + # A parsed key line always assigns a string (`.strip()`-ed at read time), + # so `prefix:` with no value yields `''`, never `None` — an `is None` + # check alone lets that empty declaration pass as "present". Treat a + # blank or whitespace-only value as missing too, so a typo'd required key + # cannot silently become a first-release baseline downstream (`''` fed to + # `last_tag_select` resolves the NONE_SENTINEL). + missing = [key for key in _REQUIRED_KEYS + if (row[_KEY_FIELD[key]] or "").strip() == ""] + if missing: + raise MissingRequiredKeyError( + f"target {row['target']!r} is missing required key(s): " + + ", ".join(missing) + ) + + +def parse_release_targets(text): + """Parse the declared-target-file GRAMMAR from `text` (already-read file + content) into a list of row dicts, one per `[target]` block, each + carrying: target, prefix, manifest (list), changelog, payload, + payload_exclude (list), rebuild, artifacts (list), provenance_manifest, + pre_tag (list), latest_eligible (bool). + + Pure — no file I/O — so it is testable with synthetic input; `load_targets` + is the one function that touches the filesystem. + + Every parser-contract violation raises its own ReleaseTargetsError + subclass; never a silent default, never a partial parse. See the module + docstring for the full list of declared exceptions. + + Cross-platform LF/CRLF editing drift means a value like + `latest-eligible: true\\r` must parse as the boolean `true`, not as an + unrecognised value that would otherwise silently drop a feature — the + exact silent-default failure this module's loud-failure contract + forbids. There is no dedicated CRLF-stripping pass: every line is + `.strip()`-ed on extraction from the block (`raw_line.strip()` below) + and every key/value pair is independently `.strip()`-ed again off the + split — Python's `str.strip()` with no argument removes `\\r` along with + every other whitespace character, so a trailing `\\r` never survives to + a comparison regardless of which layer runs first.""" + if not isinstance(text, str): + # Every declared parser-contract violation raises a ReleaseTargetsError + # subclass so a caller can catch one type (see module docstring); a + # non-string input must not be the one escape hatch that raises a bare + # TypeError instead. There is no block to find in non-text input, so + # this is the same declared answer as an absent block. + raise AbsentBlockError( + "no block found (input is not text)") + normalized = text + + opens = list(_OPEN_RE.finditer(normalized)) + if not opens: + raise AbsentBlockError("no block found") + if len(opens) > 1: + raise MultipleBlocksError( + f"found {len(opens)} opening delimiters; " + "exactly one is allowed") + + after_open = normalized[opens[0].end():] + closes = list(_CLOSE_RE.finditer(after_open)) + if not closes: + raise MalformedBlockError( + " block is never closed") + + # The GENUINE closing delimiter is the first match that sits ALONE on its + # line (only whitespace precedes it since the last newline). A match that + # is preceded by other content on the same line is embedded inside a + # declared value (e.g. `rebuild: echo `) and + # must error rather than silently become the block boundary — otherwise a + # value's embedded delimiter truncates the block and, for a REQUIRED key, + # can silently empty it (`payload: ` would parse + # with `payload == ''`). A close match that occurs entirely AFTER the + # genuine terminator — a legitimate stray mention of the delimiter text in + # prose following the block — is not inspected at all, so it can never be + # misdiagnosed as a value violation. + genuine = None + for m in closes: + line_start = after_open.rfind("\n", 0, m.start()) + 1 + prefix = after_open[line_start:m.start()] + if prefix.strip() == "": + genuine = m + break + raise DelimiterInValueError( + "a declared value contains the literal closing delimiter " + "'', which would truncate the block " + "under a naive parse instead of being treated as part of the value") + + block = after_open[:genuine.start()] + if not block.strip(): + raise EmptyBlockError(" block is empty") + + rows = [] + row = None + seen_keys = None + seen_names = set() + + for raw_line in block.split("\n"): + line = raw_line.strip() + if not line: + continue + + if line.startswith("["): + m = _HEADER_RE.match(line) + if not m: + raise MalformedBlockError( + f"malformed target header: {raw_line!r}") + name = m.group(1) + if name in seen_names: + raise DuplicateTargetError(f"duplicate target block: {name!r}") + seen_names.add(name) + if row is not None: + _finish_row(row) + rows.append(row) + row = _new_row(name) + seen_keys = set() + continue + + if row is None: + raise MalformedBlockError( + f"key line before the first [target] header: {raw_line!r}") + + idx = line.find(":") + if idx == -1: + raise MalformedBlockError( + f"malformed line (expected 'key: value'): {raw_line!r}") + key = line[:idx].strip() + value = line[idx + 1:].strip() + + if key not in _KEY_FIELD: + raise UnknownKeyError( + f"unknown key {key!r} in target {row['target']!r}") + field = _KEY_FIELD[key] + + if key in _LIST_KEYS: + row[field].append(value) + continue + + if key in seen_keys: + raise DuplicateKeyError( + f"duplicate key {key!r} in target {row['target']!r}") + seen_keys.add(key) + + if key in _BOOLEAN_KEYS: + if value == "true": + row[field] = True + elif value == "false": + row[field] = False + else: + raise InvalidBooleanError( + f"key {key!r} in target {row['target']!r} must be " + f"exactly 'true' or 'false', got {value!r}") + else: + row[field] = value + + if row is not None: + _finish_row(row) + rows.append(row) + + return rows + + +def load_targets(path): + """Read `path` and parse it via `parse_release_targets`. The one function + in this module that touches the filesystem — opened with `newline=""` so + a `\\r\\n` line ending survives into the parser exactly as it is on disk, + rather than being silently normalised away by Python's own text-mode + universal-newline translation before this module's own CRLF handling + ever runs. + + An unreadable path (missing file, permission error, a directory, ...) + raises `AbsentBlockError` rather than a bare `OSError` — the same + declared-error contract `parse_release_targets` gives every other + violation, so a `contents: write` caller can catch one exception type + instead of one type for content problems and another for I/O ones. There + is, in the end, no block to find at an unreadable path either.""" + try: + with open(path, encoding="utf-8", newline="") as fh: + text = fh.read() + except OSError as exc: + raise AbsentBlockError( + f"could not read release-targets file {path!r}: {exc}") from exc + return parse_release_targets(text) diff --git a/plugins/ca/.claude-plugin/plugin.json b/plugins/ca/.claude-plugin/plugin.json index 14bdd784..8c98f20f 100644 --- a/plugins/ca/.claude-plugin/plugin.json +++ b/plugins/ca/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "ca", "displayName": "codeArbiter", "description": "Orchestration layer for Claude Code. Routes every intent through gated skills and reviewer agents, drives spec-driven TDD, mechanically enforces the commit and audit-trail gates, decides via SMARTS, and keeps an append-only audit trail. Requires Python 3 on PATH. Dormant until you opt a repo in; run /ca:init to activate.", - "version": "2.10.8", + "version": "2.11.0", "author": { "name": "arbiterForge" }, "license": "AGPL-3.0-only", "homepage": "https://github.com/arbiterForge/codeArbiter", diff --git a/plugins/ca/hooks/_releaselib.py b/plugins/ca/hooks/_releaselib.py new file mode 100644 index 00000000..28227337 --- /dev/null +++ b/plugins/ca/hooks/_releaselib.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +# codeArbiter — portable release-lane MECHANISM (anchored per-series tag +# selection, semver comparison, publish-state classification, notes-heading +# matching, date consistency) plus the declared-target-file parser. +# +# This module is the PORTABLE half of the release helper split (issue #563). +# It ships from core/pysrc/ into every governance plugin's hooks/ directory +# (tools/sync-core.py, CI-enforced byte-identity) and therefore MUST carry no +# fact about this repository or its CI vocabulary — no plugin name, no path +# under this repository, no check-run name, no tag-namespace mapping. Every +# such fact is DATA, supplied by the caller (a required parameter) or read +# from an operator-declared file via load_targets(). A consuming repository +# supplies its own facts; this module supplies only the mechanism. +# +# Design invariants (mirror the other _*lib helpers): +# - Stdlib only; zero side effects at import (no git, no file I/O, no +# argument parsing at import time). +# - Every mechanism function (semver_key, semver_greater, last_tag_select, +# notes_heading_matches, release_dates_consistent, classify_publish_state, +# select_release_target, classify_merge_readiness, peel_tag) is pure over +# synthetic input and NEVER raises on malformed input — it degrades to the +# safe/refusing answer, per this codebase's "never raise on malformed user +# input" rule for hook-adjacent helpers. +# - The declared-target-file parser (parse_release_targets / load_targets) +# is the deliberate, documented exception to that rule: its input is not +# arbitrary user/session data but an OPERATOR-AUTHORED configuration file +# that a `contents: write` release lane later executes. A malformed +# declaration is a configuration error that MUST surface loudly to the +# operator rather than silently defaulting or partially parsing — so every +# parser-contract violation raises its own distinguishable +# ReleaseTargetsError subclass instead of returning a degraded value or +# letting a bare exception escape from deep inside the parser. +# +# Public API: +# semver_key(value) -> tuple | None +# semver_greater(current, base) -> bool +# last_tag_select(tags, prefix) -> str +# notes_heading_matches(notes_text, tag) -> bool +# release_dates_consistent(changelog_section, tag_message) -> bool +# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, +# manifest_version, release_is_nondraft) -> str +# select_release_target(*confirmations, targets) -> str +# classify_merge_readiness(check_runs, head_sha, check_name) -> str +# peel_tag(ls_remote_text, tag) -> str +# parse_release_targets(text) -> list[dict] +# load_targets(path) -> list[dict] +# +# Declared exceptions (all subclass ReleaseTargetsError): +# AbsentBlockError — no delimiter block present at all +# EmptyBlockError — the delimiter block is present but blank +# MalformedBlockError — bad `[target]` header grammar, a key before the +# first header, or an unparsable line +# UnknownKeyError — a key outside the declared grammar +# DuplicateKeyError — a scalar key repeated within one target block +# DuplicateTargetError — the same `[target]` header declared twice +# InvalidBooleanError — a boolean value that is not exactly true/false +# MultipleBlocksError — more than one delimiter block in the file +# DelimiterInValueError — a value contains the literal closing delimiter, +# which would otherwise truncate the block under a +# naive non-greedy match +# MissingRequiredKeyError — a target block is missing prefix/changelog/payload + +from __future__ import annotations + +import re + + +class ReleaseTargetsError(RuntimeError): + """Base for every declared release-targets-file parse error. Callers that + only care that the declaration was bad, not which rule it broke, can catch + this one type; callers that need to react differently per violation catch + the specific subclass.""" + + +class AbsentBlockError(ReleaseTargetsError): + """No delimiter block is present in the file at all.""" + + +class EmptyBlockError(ReleaseTargetsError): + """The delimiter block is present but contains no declaration content.""" + + +class MalformedBlockError(ReleaseTargetsError): + """A `[target]` header is malformed (empty, or carries a character outside + `[A-Za-z0-9._-]`), a key line appears before the first header, or a line is + neither a header nor a `key: value` pair.""" + + +class UnknownKeyError(ReleaseTargetsError): + """A key outside the declared grammar (e.g. a typo) was used.""" + + +class DuplicateKeyError(ReleaseTargetsError): + """A scalar (non-repeating) key was declared twice within one target block.""" + + +class DuplicateTargetError(ReleaseTargetsError): + """The same `[target]` header was declared more than once.""" + + +class InvalidBooleanError(ReleaseTargetsError): + """A boolean-typed value was neither exactly `true` nor exactly `false`.""" + + +class MultipleBlocksError(ReleaseTargetsError): + """More than one delimiter block was found in the file.""" + + +class DelimiterInValueError(ReleaseTargetsError): + """A declared value contains the literal closing-delimiter text, which + would otherwise silently truncate the block under a naive non-greedy + match rather than being treated as part of the value.""" + + +class MissingRequiredKeyError(ReleaseTargetsError): + """A target block is missing one of the required keys (prefix, changelog, + payload).""" + + +# A `2.9.1`-style series tag is exactly `MAJOR.MINOR.PATCH` — no +# suffix. The anchored form already excludes pre-releases (`2.6.0-beta.1`); +# _PRERELEASE_MARKERS is the explicit, legible second line of defense. +_RELEASE_RE_CACHE = {} + + +def _release_re(prefix): + """The anchored `MAJOR.MINOR.PATCH` matcher for one release series.""" + rx = _RELEASE_RE_CACHE.get(prefix) + if rx is None: + rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") + _RELEASE_RE_CACHE[prefix] = rx + return rx + + +_PRERELEASE_MARKERS = ("-beta", "-rc", "-alpha") + +# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the +# Keep-a-Changelog `## [X.Y.Z] - DATE` bracket form. The capture is the bare +# `X.Y.Z`; the optional leading `v` and the surrounding brackets sit OUTSIDE +# the group, so heading comparison is style-agnostic. Any separator is +# allowed between version and date. Plus the annotated-tag `Released-at:` +# footer. +_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE) +_CHANGELOG_DATE_RE = re.compile( + r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE) +_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})") + +# Full SemVer, including the pre-release and build-metadata tails a release +# tag never carries but a version MANIFEST can. The anchored `_release_re` +# above deliberately rejects those, because it selects a published release +# series; this one parses a version for ORDERING, which is a different +# question and needs the tail. +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + +def semver_key(value): + """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer. + + Non-raising per this module's mechanism-function invariant. Build + metadata is parsed and discarded: SemVer §10 says it is not part of + precedence, so `1.0.0+a` and `1.0.0+b` compare equal. + """ + if not isinstance(value, str): + return None + match = SEMVER.fullmatch(value) + if match is None: + return None + prerelease = match.group(4) + if prerelease is None: + pre_key = None + else: + pre_key = tuple( + (0, int(part)) if part.isdigit() else (1, part) + for part in prerelease.split(".") + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key + + +def semver_greater(current, base): + """True iff `current` is a STRICT SemVer advance over `base`. + + The single definition of "advance" every payload-version gate shares. + Degrades to False when either side is unparseable, which refuses the + gate rather than passing it. Pre-release ordering follows SemVer §11: a + pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric + identifiers compare numerically and rank below alphanumeric ones. + """ + current_key = semver_key(current) + base_key = semver_key(base) + if current_key is None or base_key is None: + return False + if current_key[:3] != base_key[:3]: + return current_key[:3] > base_key[:3] + current_pre, base_pre = current_key[3], base_key[3] + if current_pre is None: + return base_pre is not None + if base_pre is None: + return False + return current_pre > base_pre + + +def _bare_version(tag): + """`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer. + + Lets the heading match compare a tag against a bracket-style changelog + heading without caring about either spelling. + + Anchored on the SemVer at the END rather than by stripping a known + prefix, so a namespaced series' tag (`vMAJOR.MINOR.PATCH`) works + without the prefix being known here — stripping only a LEADING "v" is + right for a bare `v2.9.1` and wrong for any namespaced series, since + `"myapp-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31` + parsed out of the heading.""" + if not isinstance(tag, str): + return tag + text = tag.strip().strip("[]") + match = re.search(r"(\d+\.\d+\.\d+.*)$", text) + return match.group(1) if match else text.lstrip("v") + + +def last_tag_select(tags, prefix): + """Return the highest SemVer tag in `tags` for ONE release series, + excluding pre-releases (`-beta`/`-rc`/`-alpha`). Returns NONE_SENTINEL + when the series has no release tag yet. + + `prefix` selects the series and is REQUIRED — this repository's default + was a repo-specific fact (which series is "the" release) and could not + survive as a module default without smuggling that fact back in. The + caller supplies the prefix for the series it means, typically a value + loaded from a declared row (see `load_targets`). + + This is the single source of `LAST_TAG`, replacing an inline grep + one-liner: bare `git describe --tags` returns the nearest tag by commit- + graph ANCESTRY, which in a multi-series repo is routinely another + series' tag, and silently bases an entire release on the wrong baseline. + + Series isolation is a property of the ANCHORED match rather than a list + of exclusions to maintain: `^v` cannot match `myapp-v0.1.30`, and + `^myapp-v` cannot match `v2.9.1`. A new series therefore cannot leak into + an existing one by being forgotten in an exclusion list.""" + best = None # ((major, minor, patch), original_tag) + if not isinstance(tags, (list, tuple)): + return NONE_SENTINEL + if not isinstance(prefix, str) or not prefix: + return NONE_SENTINEL + matcher = _release_re(prefix) + for t in tags: + if not isinstance(t, str): + continue + m = matcher.match(t) + if not m: + continue + # Tested against the VERSION portion only, after the prefix is + # stripped — never the whole tag. A consumer's own prefix can + # legitimately contain one of these substrings (`web-beta-v`, + # `api-rc-v`); testing the whole tag would reject every one of that + # series' real releases, reading `` as "never released" for a + # series that has releases. + if any(marker in t[len(prefix):] for marker in _PRERELEASE_MARKERS): + continue + ver = tuple(int(g) for g in m.groups()) + if best is None or ver > best[0]: + best = (ver, t) + return best[1] if best else NONE_SENTINEL + + +NONE_SENTINEL = "" + + +def notes_heading_matches(notes_text, tag): + """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z` + or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as + `tag`. A stale notes-file (whose first section is an older version) + returns False, so a release lane cannot publish the wrong changelog + section under the right tag. Missing heading or non-string input -> + False.""" + if not isinstance(notes_text, str) or not isinstance(tag, str): + return False + m = _HEADING_RE.search(notes_text) + if not m: + return False + return m.group(1) == _bare_version(tag) + + +def release_dates_consistent(changelog_section, tag_message): + """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE` + or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in + `tag_message`. Guards against the date being hand-typed inconsistently + across surfaces. Either date absent, or non-string input -> False.""" + if not isinstance(changelog_section, str) or not isinstance(tag_message, str): + return False + cm = _CHANGELOG_DATE_RE.search(changelog_section) + tm = _RELEASED_AT_RE.search(tag_message) + if not cm or not tm: + return False + return cm.group(1) == tm.group(1) + + +def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, + manifest_version, release_is_nondraft): + """Classify a (re)publish attempt so a release lane can resume a + half-finished publish instead of dead-ending on 'tag exists -> STOP'. + Returns one of: + + publish_fresh - no tag yet; the normal path. + already_published - the tag is at HEAD and a non-draft release exists. + resume_publish - tag is at HEAD and its version matches the + manifest, but no non-draft release exists (tag + pushed, release never created) -> finish publish. + abort_mismatch - tag points at a non-HEAD commit, or its version + disagrees with the manifest -> STOP, never overwrite. + + Mismatch OUTRANKS publication state. An existing release used to + short-circuit to `already_published` before the tag was compared to + HEAD, so a resumed publish could silently accept a release whose tag + installs a different snapshot. The tag is what consumers actually fetch; + if it does not name this commit, nothing about the release makes the + state safe. + """ + if not tag_exists: + return "publish_fresh" + if tag_sha != head_sha or tag_version != manifest_version: + return "abort_mismatch" + if release_is_nondraft: + return "already_published" + return "resume_publish" + + +def select_release_target(*confirmations, targets): + """Resolve which single target a release dispatch selected. + `confirmations` are the per-target version inputs, positionally aligned + with `targets`. `targets` is REQUIRED — the register of releasable names + is a repo-specific fact and cannot survive as a module default. Returns + one of: + + - exactly one input was supplied; the matching name from + `targets`. + none - no input was supplied; there is nothing to publish. + multiple - more than one; the dispatch is ambiguous and MUST be + refused. + arity - `confirmations` and `targets` are not the same length. + + Selection is one decision, made once, by a caller that holds no write + token of its own, so a dispatch that supplies more than one confirmation + can never start two `contents: write` publishers. Blank-ish input + (whitespace, non-string) counts as "not selected" so a stray space can + never read as a second target. + + The count is checked against `targets` rather than zipped-to-shortest on + purpose: a caller wired for fewer targets than were actually supplied + would otherwise silently resolve the wrong one. `arity` is not a target + and is meant to match no dispatch case, so a caller's fail-closed default + arm refuses it - and, like every other return here, it is a LABEL rather + than an exception, so a caller's contract of "prints a label and never + raises" holds.""" + def _selected(value): + return isinstance(value, str) and value.strip() != "" + + if not isinstance(targets, (list, tuple)): + return "arity" + if len(confirmations) != len(targets): + return "arity" + selected = [target for target, value in zip(targets, confirmations) + if _selected(value)] + if len(selected) > 1: + return "multiple" + if selected: + return selected[0] + return "none" + + +def classify_merge_readiness(check_runs, head_sha, check_name): + """Classify the merge-readiness evidence for ONE exact commit. `check_runs` + is the `check_runs` array from a commit's check-runs API response. + `check_name` — the single aggregate check that means "every required job + for this commit concluded green" — is REQUIRED: its exact text is a + repo-specific fact (this codebase's own CI vocabulary) and cannot survive + as a module default. Returns one of: + + green - the gate ran for this commit, completed, and succeeded. + missing - no check run by that name is present at all. + pending - present but not `completed` (queued / in_progress / ...). + sha_mismatch - a matching run reports a different `head_sha`. + not_successful - completed with any conclusion other than `success` + (failure, cancelled, skipped, timed_out, neutral, ...). + + A hosted release workflow that only proves it was dispatched from a + protected branch shows how a commit ENTERED that branch, not that + post-merge evidence exists for the exact commit about to be tagged. + + Fail-closed throughout: unparseable input is `missing`, and several runs + share one name only when a re-run is in flight - we cannot tell which + verdict is authoritative, so EVERY matching run must be green.""" + if not isinstance(check_runs, list): + return "missing" + matching = [run for run in check_runs + if isinstance(run, dict) and run.get("name") == check_name] + if not matching: + return "missing" + if any(run.get("head_sha") != head_sha for run in matching): + return "sha_mismatch" + if any(run.get("status") != "completed" for run in matching): + return "pending" + if any(run.get("conclusion") != "success" for run in matching): + return "not_successful" + return "green" + + +def peel_tag(ls_remote_text, tag): + """Resolve the COMMIT a remote tag names, from `git ls-remote --tags` + output. Returns "" when the tag is absent. + + An annotated tag's own object id is not the commit it points at; the + peeled `refs/tags/^{}` line is. A workflow that treats any remote + hit as a resumable publish without comparing the tag to the current + commit can accept a stale tag as a successful rerun and publish for the + wrong commit. Matching is exact on the ref name, so `v2.6.0` is never + resolved from `v2.6.0-beta.1`.""" + if not isinstance(ls_remote_text, str) or not isinstance(tag, str): + return "" + direct = peeled = "" + ref = f"refs/tags/{tag}" + for line in ls_remote_text.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + sha, name = parts + if name == ref + "^{}": + peeled = sha + elif name == ref: + direct = sha + return peeled or direct + + +# --------------------------------------------------------------------------- # +# Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of +# `key: value` lines inside the HTML-comment delimiter convention this +# codebase's path-scope reader (`_scopelib.py`) already uses — reused here +# rather than inventing a second delimiter syntax. +# --------------------------------------------------------------------------- # + +_OPEN_RE = re.compile(r"") +_CLOSE_RE = re.compile(r"") +_HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$") + +# A key not in _LIST_KEYS is scalar: exactly one value per target block, a +# second occurrence of the same key within one block is a DuplicateKeyError. +# List keys repeat by design and preserve declaration order. +_LIST_KEYS = frozenset({"manifest", "artifacts", "pre-tag", "payload-exclude"}) +_BOOLEAN_KEYS = frozenset({"latest-eligible"}) +_REQUIRED_KEYS = ("prefix", "changelog", "payload") + +# Grammar key -> row field name (rows use `_` throughout, the grammar uses +# `-`, matching this codebase's `key: value` / `snake_case` convention split). +_KEY_FIELD = { + "prefix": "prefix", + "changelog": "changelog", + "payload": "payload", + "rebuild": "rebuild", + "provenance-manifest": "provenance_manifest", + "latest-eligible": "latest_eligible", + "manifest": "manifest", + "artifacts": "artifacts", + "pre-tag": "pre_tag", + "payload-exclude": "payload_exclude", +} + + +def _new_row(name): + return { + "target": name, + "prefix": None, + "manifest": [], + "changelog": None, + "payload": None, + "payload_exclude": [], + "rebuild": None, + "artifacts": [], + "provenance_manifest": None, + "pre_tag": [], + "latest_eligible": False, + } + + +def _finish_row(row): + # A parsed key line always assigns a string (`.strip()`-ed at read time), + # so `prefix:` with no value yields `''`, never `None` — an `is None` + # check alone lets that empty declaration pass as "present". Treat a + # blank or whitespace-only value as missing too, so a typo'd required key + # cannot silently become a first-release baseline downstream (`''` fed to + # `last_tag_select` resolves the NONE_SENTINEL). + missing = [key for key in _REQUIRED_KEYS + if (row[_KEY_FIELD[key]] or "").strip() == ""] + if missing: + raise MissingRequiredKeyError( + f"target {row['target']!r} is missing required key(s): " + + ", ".join(missing) + ) + + +def parse_release_targets(text): + """Parse the declared-target-file GRAMMAR from `text` (already-read file + content) into a list of row dicts, one per `[target]` block, each + carrying: target, prefix, manifest (list), changelog, payload, + payload_exclude (list), rebuild, artifacts (list), provenance_manifest, + pre_tag (list), latest_eligible (bool). + + Pure — no file I/O — so it is testable with synthetic input; `load_targets` + is the one function that touches the filesystem. + + Every parser-contract violation raises its own ReleaseTargetsError + subclass; never a silent default, never a partial parse. See the module + docstring for the full list of declared exceptions. + + Cross-platform LF/CRLF editing drift means a value like + `latest-eligible: true\\r` must parse as the boolean `true`, not as an + unrecognised value that would otherwise silently drop a feature — the + exact silent-default failure this module's loud-failure contract + forbids. There is no dedicated CRLF-stripping pass: every line is + `.strip()`-ed on extraction from the block (`raw_line.strip()` below) + and every key/value pair is independently `.strip()`-ed again off the + split — Python's `str.strip()` with no argument removes `\\r` along with + every other whitespace character, so a trailing `\\r` never survives to + a comparison regardless of which layer runs first.""" + if not isinstance(text, str): + # Every declared parser-contract violation raises a ReleaseTargetsError + # subclass so a caller can catch one type (see module docstring); a + # non-string input must not be the one escape hatch that raises a bare + # TypeError instead. There is no block to find in non-text input, so + # this is the same declared answer as an absent block. + raise AbsentBlockError( + "no block found (input is not text)") + normalized = text + + opens = list(_OPEN_RE.finditer(normalized)) + if not opens: + raise AbsentBlockError("no block found") + if len(opens) > 1: + raise MultipleBlocksError( + f"found {len(opens)} opening delimiters; " + "exactly one is allowed") + + after_open = normalized[opens[0].end():] + closes = list(_CLOSE_RE.finditer(after_open)) + if not closes: + raise MalformedBlockError( + " block is never closed") + + # The GENUINE closing delimiter is the first match that sits ALONE on its + # line (only whitespace precedes it since the last newline). A match that + # is preceded by other content on the same line is embedded inside a + # declared value (e.g. `rebuild: echo `) and + # must error rather than silently become the block boundary — otherwise a + # value's embedded delimiter truncates the block and, for a REQUIRED key, + # can silently empty it (`payload: ` would parse + # with `payload == ''`). A close match that occurs entirely AFTER the + # genuine terminator — a legitimate stray mention of the delimiter text in + # prose following the block — is not inspected at all, so it can never be + # misdiagnosed as a value violation. + genuine = None + for m in closes: + line_start = after_open.rfind("\n", 0, m.start()) + 1 + prefix = after_open[line_start:m.start()] + if prefix.strip() == "": + genuine = m + break + raise DelimiterInValueError( + "a declared value contains the literal closing delimiter " + "'', which would truncate the block " + "under a naive parse instead of being treated as part of the value") + + block = after_open[:genuine.start()] + if not block.strip(): + raise EmptyBlockError(" block is empty") + + rows = [] + row = None + seen_keys = None + seen_names = set() + + for raw_line in block.split("\n"): + line = raw_line.strip() + if not line: + continue + + if line.startswith("["): + m = _HEADER_RE.match(line) + if not m: + raise MalformedBlockError( + f"malformed target header: {raw_line!r}") + name = m.group(1) + if name in seen_names: + raise DuplicateTargetError(f"duplicate target block: {name!r}") + seen_names.add(name) + if row is not None: + _finish_row(row) + rows.append(row) + row = _new_row(name) + seen_keys = set() + continue + + if row is None: + raise MalformedBlockError( + f"key line before the first [target] header: {raw_line!r}") + + idx = line.find(":") + if idx == -1: + raise MalformedBlockError( + f"malformed line (expected 'key: value'): {raw_line!r}") + key = line[:idx].strip() + value = line[idx + 1:].strip() + + if key not in _KEY_FIELD: + raise UnknownKeyError( + f"unknown key {key!r} in target {row['target']!r}") + field = _KEY_FIELD[key] + + if key in _LIST_KEYS: + row[field].append(value) + continue + + if key in seen_keys: + raise DuplicateKeyError( + f"duplicate key {key!r} in target {row['target']!r}") + seen_keys.add(key) + + if key in _BOOLEAN_KEYS: + if value == "true": + row[field] = True + elif value == "false": + row[field] = False + else: + raise InvalidBooleanError( + f"key {key!r} in target {row['target']!r} must be " + f"exactly 'true' or 'false', got {value!r}") + else: + row[field] = value + + if row is not None: + _finish_row(row) + rows.append(row) + + return rows + + +def load_targets(path): + """Read `path` and parse it via `parse_release_targets`. The one function + in this module that touches the filesystem — opened with `newline=""` so + a `\\r\\n` line ending survives into the parser exactly as it is on disk, + rather than being silently normalised away by Python's own text-mode + universal-newline translation before this module's own CRLF handling + ever runs. + + An unreadable path (missing file, permission error, a directory, ...) + raises `AbsentBlockError` rather than a bare `OSError` — the same + declared-error contract `parse_release_targets` gives every other + violation, so a `contents: write` caller can catch one exception type + instead of one type for content problems and another for I/O ones. There + is, in the end, no block to find at an unreadable path either.""" + try: + with open(path, encoding="utf-8", newline="") as fh: + text = fh.read() + except OSError as exc: + raise AbsentBlockError( + f"could not read release-targets file {path!r}: {exc}") from exc + return parse_release_targets(text) From 11a938ea3b88434bd69e0de98a7df57105d925d6 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Fri, 31 Jul 2026 05:51:43 -0400 Subject: [PATCH 06/66] feat(release): make the shim CI-safe and wire the resolution trace (#563) An adversarial pass falsified this slice's central claim. The shim did break CI, in three independent places, two of them already flagged by the repo's own guards while the slice was reported verified. test_release_workflow.py went from green to 41 of 60 failing, because it builds a synthetic tree containing only the shim while the shim resolves the generated mechanism at import. ci.yml describes that suite as the only guard on the release workflow's publish gates. The fix is in the test, which now materializes the mechanism into the synthetic tree. Deliberately not in the shim: a fallback tolerating a missing mechanism is the silent default the spec forbids, and would let a genuinely broken install pass. test_release_trace.py was referenced by no workflow and no sibling script, so the campaign's entire proof of no-behavior-change never ran, and the orphan guard was already red naming it. It is now wired into the hooks job with fetch-depth 0 and fetch-tags, because both shallow-clone failures were reproduced: the pinned SHA is unreachable without full depth and the live-tag assertion needs tags. A preflight hard-fails with a named cause rather than skipping, since a skip would hide precisely this. The new shim test shelled out to the payload gate with an origin/main base the hooks checkout cannot resolve, failing on every PR run and coupling that job to ca's payload-versus-version state. It now asserts the import identity the real consumer depends on, which is git-free and a stronger claim. ca-pi's declared rebuild was not runnable: build.mjs resolves inputs against cwd, so it needs the cd form its three siblings already used. As written it would have blocked the ca-pi release lane permanently once slice 3 lands, and a mutant replacing it with a no-op survived the whole suite. The trace also certified "changed nothing" across a function this campaign deliberately changed. last_tag_select's marker test moved from the whole tag to the prefix-stripped version, which was the consumer-portability fix; on every fixture prefix old and new agree, so the claim read as true only because no fixture exercised the change. A marker-bearing row now asserts the intended divergence, so the trace says exactly this changed, on purpose. Refs: #563, #568 --- .../portable-release-and-protected-state.md | 44 +- .codearbiter/release-targets.md | 81 +++ .../specs/release-portable-fixture.md | 88 ++- .codearbiter/sprint-log.md | 2 + .codearbiter/tech-stack.md | 8 + .github/scripts/_releaselib.py | 439 +++++--------- .../fixtures/release-trace/commit-graph.json | 39 ++ .../manifests/ca-codex-plugin.json | 4 + .../manifests/ca-pi-package.json | 4 + .../manifests/ca-pi-root-package.json | 4 + .../release-trace/manifests/ca-plugin.json | 4 + .../manifests/ca-sandbox-plugin.json | 4 + .../fixtures/release-trace/release-targets.md | 48 ++ .../scripts/fixtures/release-trace/tags.txt | 8 + .github/scripts/test_release_lib.py | 392 ++++++++++++ .github/scripts/test_release_trace.py | 574 ++++++++++++++++++ .github/scripts/test_release_workflow.py | 13 + .github/workflows/ci.yml | 21 + core/pysrc/_releaselib.py | 13 +- plugins/ca-codex/hooks/_releaselib.py | 13 +- plugins/ca-pi/hooks/_releaselib.py | 13 +- plugins/ca/hooks/_releaselib.py | 13 +- 22 files changed, 1505 insertions(+), 324 deletions(-) create mode 100644 .codearbiter/release-targets.md create mode 100644 .github/scripts/fixtures/release-trace/commit-graph.json create mode 100644 .github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json create mode 100644 .github/scripts/fixtures/release-trace/manifests/ca-pi-package.json create mode 100644 .github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json create mode 100644 .github/scripts/fixtures/release-trace/manifests/ca-plugin.json create mode 100644 .github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json create mode 100644 .github/scripts/fixtures/release-trace/release-targets.md create mode 100644 .github/scripts/fixtures/release-trace/tags.txt create mode 100644 .github/scripts/test_release_trace.py diff --git a/.codearbiter/plans/portable-release-and-protected-state.md b/.codearbiter/plans/portable-release-and-protected-state.md index f2fd1879..ecc043a0 100644 --- a/.codearbiter/plans/portable-release-and-protected-state.md +++ b/.codearbiter/plans/portable-release-and-protected-state.md @@ -31,6 +31,7 @@ duplicated, so the two documents cannot drift. | A-1.9 | *(transitional)* shim re-exports mechanism, still exposes `RELEASE_TAG_PREFIXES` | | A-1.10 | this repo's four rows load; target+prefix equal pre-change constants | | A-1.11 | resolution trace reproduces a recorded pre-change run for `ca` and `ca-pi` | +| A-1.12 | the trace asserts the **intended** `last_tag_select` divergence on a marker-bearing prefix, so it says "exactly this changed, on purpose" rather than "nothing changed" | | A-2.1 | pre-tag commands execute in declared order | | A-2.2 | non-zero exit blocks | | A-2.3 | dirty tree blocks; assertion precedes `rebuild` | @@ -57,11 +58,15 @@ duplicated, so the two documents cannot drift. | A-5.4 | back-fill persists; second run reads | | A-5.5 | first-release changelog baseline instead of per-commit BLOCK | | A-5.6 | provenance triggers are the rows' referenced paths | +| A-6.0 | the release skill itself resolves targets from the declared file (table becomes loader, helpers repoint, provenance step reads the row field, hosted-lane prose conditionalized) | | A-6.1 | reference-form guard over `core/surface/skills/**` | | A-6.2 | `subagent-driven-development` farm.js reference resolves | | A-6.3 | `decision-lifecycle` reworded to conditional CI reference | | A-6.4 | `commands/release.md` matches the skill | | A-6.5 | docs-site guide distinguishes general lane from this repo | +| A-6.6 | portability proven in a clean consumer repo built by `git archive HEAD` — reference resolution, prose-extracted lane driver, and a narrow agent-judgment layer; assertions on derived outputs, never exit codes | +| A-6.7 | this repo still releases — pinned old lane and new lane derive the same version, window and composed tag **message file** from live HEAD, with **zero refs created** | +| A-6.8 | the agent-layer proof cannot rot: a `pre-tag` check asserts the recorded skill-content hash still matches what ships, so editing the skill without re-running the proof blocks the next release | ### Workstream B — protected-state machinery @@ -165,12 +170,12 @@ Paths resolve per `coding-standards.md`: Python hooks in `plugins/ca/hooks/`, sh | T-22 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k parser_contract` — 8 violations, 8 distinguishable errors | parser contract | A-1.6 | T-21 | ACCEPTED | | T-23 | `core/pysrc/_releaselib.py` | `test_release_lib.py -k empty_block` raises the declared error | empty-block error | A-1.7 | T-22 | ACCEPTED | | T-24 | `.github/scripts/test_release_lib.py` | `-k series_isolation` — `v` and `ca-pi-v` resolve independently | series isolation | A-1.8 | T-23 | ACCEPTED | -| T-25 | `.github/scripts/_releaselib.py` | `python .github/scripts/payload_version_gate.py --plugin plugins/ca --base origin/main` exits 0 (bare invocation exits 2 — args are required) | transitional shim | A-1.9 | T-24 | PENDING | -| T-26 | `.codearbiter/release-targets.md` | `python .github/scripts/test_release_lib.py -k this_repo_rows` — 4 rows load, prefixes match constants, **and every one of the four declares `provenance-manifest`** | repo rows declared | A-1.10 | T-25 | PENDING | -| T-27a | `.github/scripts/fixtures/release-trace/` | `python .github/scripts/test_release_trace.py -k fixture_shape` — frozen tag list, manifests, commit graph, 4 rows | trace fixture | A-1.11 | T-26 | PENDING | -| T-27b | `.github/scripts/test_release_trace.py` | `-k old_lane_loads` — helpers pinned via `git show :.github/scripts/_releaselib.py` | pinned old lane | A-1.11 | T-27a | PENDING | -| T-27c | `.github/scripts/test_release_trace.py` | `-k old_lane_live` — the transcribed old lane resolves `ca`'s real last tag against the live repo; **divergence is a STOP, not a fixup** | old-lane validation | A-1.11 | T-27b | PENDING | -| T-27d | `.github/scripts/test_release_trace.py` | `-k trace_matches` — new lane reproduces the recorded variable dict for `ca` **and** `ca-pi` | trace assertion | A-1.11 | T-27c | PENDING | +| T-25 | `.github/scripts/_releaselib.py` | `python .github/scripts/payload_version_gate.py --plugin plugins/ca --base origin/main` exits 0 (bare invocation exits 2 — args are required) | transitional shim | A-1.9 | T-24 | ACCEPTED | +| T-26 | `.codearbiter/release-targets.md` | `python .github/scripts/test_release_lib.py -k this_repo_rows` — 4 rows load, prefixes match constants, **and every one of the four declares `provenance-manifest`** | repo rows declared | A-1.10 | T-25 | ACCEPTED | +| T-27a | `.github/scripts/fixtures/release-trace/` | `python .github/scripts/test_release_trace.py -k fixture_shape` — frozen tag list, manifests, commit graph, 4 rows | trace fixture | A-1.11 | T-26 | ACCEPTED | +| T-27b | `.github/scripts/test_release_trace.py` | `-k old_lane_loads` — helpers pinned via `git show :.github/scripts/_releaselib.py` | pinned old lane | A-1.11 | T-27a | ACCEPTED | +| T-27c | `.github/scripts/test_release_trace.py` | `-k old_lane_live` — the transcribed old lane resolves `ca`'s real last tag against the live repo; **divergence is a STOP, not a fixup** | old-lane validation | A-1.11 | T-27b | ACCEPTED | +| T-27d | `.github/scripts/test_release_trace.py` | `-k trace_matches` — new lane reproduces the recorded variable dict for `ca` **and** `ca-pi` | trace assertion | A-1.11 | T-27c | ACCEPTED | | T-28 | `core/surface/skills/release/SKILL.md` | `test_release_lib.py -k pre_tag_order` — declared order preserved | pre-tag order | A-2.1 | T-27 | PENDING | | T-29 | `core/pysrc/_releaselib.py` | `-k pre_tag_exit` — non-zero exit blocks | pre-tag exit | A-2.2 | T-28 | PENDING | | T-30 | `core/pysrc/_releaselib.py` | `-k pre_tag_dirty` — dirty tree blocks, assertion precedes rebuild | clean-tree gate | A-2.3 | T-29 | PENDING | @@ -273,10 +278,29 @@ consumer proof runs in a scratch repo with no file from this repository present. | id | path(s) | verification | maps-to | covers | depends | status | |---|---|---|---|---|---|---| -| T-73 | `.github/scripts/test_consumer_smoke.py` | scratch repo built: one `package.json`, one `CHANGELOG.md`, tag `v1.2.3`, codeArbiter installed, **zero files from this repo** | consumer fixture | A-6.6 | T-72 | PENDING | -| T-74 | `.github/scripts/test_consumer_smoke.py` | `-k backfill_detects` — no declared file → detected shape presented, refuses to proceed unconfirmed | consumer back-fill | A-6.6 | T-73 | PENDING | -| T-75 | `.github/scripts/test_consumer_smoke.py` | `-k consumer_end_to_end` — target resolution, window derivation, bump classification and changelog rolling all succeed with no non-payload path touched | portability proof | A-6.6 | T-74 | PENDING | -| T-76 | `.github/scripts/test_release_trace.py` | `-k this_repo_still_releases` — `/ca:release ca` composes a tag on a scratch branch at the version the pre-change lane would derive; tag discarded, never pushed | this repo still releases | A-6.7 | T-75 | PENDING | +**Pulled forward as a ratchet.** T-73a/T-73b run right after T-27d, not at the end. The loader and +library already landed at `0664506`, so the fixture is feasible now — and authoring it at the end +means transcribing its expected values from the implementation it is supposed to check. + +The ratchet is what makes early landing safe. A long-red test cannot be a required check while red, +so it enforces nothing for weeks, and a test red for its whole life gets edited into passing on the +day it finally matters. Instead T-73b compares the unresolved-reference set against a **committed +known-failures list** — green and required from day one, failing whenever that set changes in +**either** direction without the list moving in the same diff. It catches both a shrink nobody +recorded and a *new* contaminating reference sneaking in mid-campaign, which a plain red test would +silently absorb. T-41a–d, T-69 and T-70 each shrink the list in their own commit; T-79 asserts it is +empty and deletes the ratchet. + +| id | path(s) | verification | maps-to | covers | depends | status | +|---|---|---|---|---|---|---| +| T-73a | `.github/scripts/test_consumer_smoke.py` | scratch consumer repo built; plugin materialized by `git archive HEAD -- plugins/ca` into a scratch cache — **not** an in-repo `CLAUDE_PLUGIN_ROOT` pointer, **not** a recursive copy (both carry uncommitted and gitignored dev-tree state) | consumer fixture | A-6.6 | T-27d | PENDING | +| T-73b | `.github/scripts/test_consumer_smoke.py`, `.github/scripts/known-unresolved-refs.txt` | `-k reference_resolution_ratchet` — unresolved refs in the **installed** SKILL.md equal the committed list exactly; fails on any change in either direction | reference ratchet | A-6.6 | T-73a | PENDING | +| T-74 | `.github/scripts/test_consumer_smoke.py` | `-k lane_driver` — the mechanical sequence runs via **invocation strings extracted from the skill text**, never direct imports, so prose/CLI drift fails here | lane driver | A-6.6 | T-73b | PENDING | +| T-75 | `.github/scripts/test_consumer_smoke.py` | `-k consumer_end_to_end` — asserts on derived **outputs** (resolved row, `LAST_TAG`, computed bump, rolled changelog text), never exit codes alone | portability proof | A-6.6 | T-74 | PENDING | +| T-76 | `.github/scripts/test_consumer_smoke.py` | `-k backfill_detects` — no declared file, so the detected shape is presented and does not proceed unconfirmed | consumer back-fill | A-6.6 | T-75 | PENDING | +| T-77 | `.github/scripts/test_release_trace.py` | `-k this_repo_still_releases` — pinned pre-change lane and new lane both derive next version, window and composed tag **message file** from live HEAD; equality asserted; **zero refs created** | this repo still releases | A-6.7 | T-76 | PENDING | +| T-78 | `.codearbiter/reports/agent-lane-proof.json` | scripted agent scenarios run against the scratch fixture — happy path, missing-footer BLOCK, back-fill confirm and refuse — with outcomes and the **content hash of the shipped skill** recorded | agent judgment layer | A-6.6 | T-77 | PENDING | +| T-79 | `.codearbiter/release-targets.md`, `.github/scripts/check_skill_proof_fresh.py` | a `pre-tag` row asserts the recorded skill hash still matches the shipped skill, so editing the skill without re-running the proof **blocks the next release**; and the known-failures list is asserted empty, retiring the ratchet | proof freshness + ratchet retirement | A-6.8, A-6.1 | T-78 | PENDING | ## Pre-run dispositions (maintainer-answered 2026-07-31) diff --git a/.codearbiter/release-targets.md b/.codearbiter/release-targets.md new file mode 100644 index 00000000..593a613e --- /dev/null +++ b/.codearbiter/release-targets.md @@ -0,0 +1,81 @@ +# Release targets — codeArbiter (this repository) + +Declared-target-file for the portable `/ca:release` lane (issue #563, +`.codearbiter/specs/release-portable-fixture.md`). Parsed by +`load_targets()` / `parse_release_targets()` (`core/pysrc/_releaselib.py`, +vendored byte-identically into every governance plugin's `hooks/` directory) +per the spec's "File grammar" and "Parser contract" sections, which are +authoritative over this file's shape. + +Four rows, one per shipped plugin, sourced from +`plugins/ca/skills/release/SKILL.md`'s Targets table. Every row declares +`provenance-manifest: .github/published-tags.json` (maintainer decision, +2026-07-31): an absent field silently skips the tag-provenance recording +step, and all four of this repository's release lanes record provenance. + +Only `ca` is `latest-eligible` — one repo-wide "Latest" badge across four +release series; a sibling claiming it would hide `ca`'s current release from +every visitor. + +`ca-pi` has **two** manifests — the plugin's own `plugins/ca-pi/package.json` +plus the GENERATED repo-root `package.json` Pi actually installs (never +hand-edited; regenerated via `tools/build-host-packages.py`) — and its +payload **excludes** `tools/`, which ships neither generated policy nor a +built runtime artifact under `extensions/`. + +This file's `pre-tag` entries are operator-authored executable input that a +`contents: write` release lane later runs, which is why it is a *declared* +candidate for the H-22 protected-state registry +(`.codearbiter/security-controls.md`, "Protected-state registry"). **It is +NOT protected yet** — the registry ships EMPTY as of this slice, and this +file's own enrolment is tracked, PENDING, as task T-33 +(`.codearbiter/plans/portable-release-and-protected-state.md`). Until T-33 +lands, this file sits on disk with no marker-gated write protection, same as +any other file (M-1, adversarial review 2026-07-31). + + +[ca] +prefix: v +manifest: plugins/ca/.claude-plugin/plugin.json +changelog: CHANGELOG.md +payload: plugins/ca/ +rebuild: cd plugins/ca/tools && npm run build +artifacts: plugins/ca/tools/farm.js +provenance-manifest: .github/published-tags.json +latest-eligible: true +pre-tag: python3 .github/scripts/check_badge_consistency.py +pre-tag: python3 .github/scripts/check_command_catalog.py + +[ca-codex] +prefix: ca-codex-v +manifest: plugins/ca-codex/.codex-plugin/plugin.json +changelog: plugins/ca-codex/CHANGELOG.md +payload: plugins/ca-codex/ +provenance-manifest: .github/published-tags.json +latest-eligible: false + +[ca-sandbox] +prefix: ca-sandbox-v +manifest: plugins/ca-sandbox/.claude-plugin/plugin.json +changelog: plugins/ca-sandbox/CHANGELOG.md +payload: plugins/ca-sandbox/ +rebuild: cd plugins/ca-sandbox/tools && npm run build +artifacts: plugins/ca-sandbox/tools/sandbox.js +artifacts: plugins/ca-sandbox/tools/claude-inside.js +provenance-manifest: .github/published-tags.json +latest-eligible: false + +[ca-pi] +prefix: ca-pi-v +manifest: plugins/ca-pi/package.json +manifest: package.json +changelog: plugins/ca-pi/CHANGELOG.md +payload: plugins/ca-pi/ +payload-exclude: plugins/ca-pi/tools/ +rebuild: cd plugins/ca-pi/tools && node build.mjs +artifacts: plugins/ca-pi/extensions/codearbiter.js +artifacts: plugins/ca-pi/extensions/codearbiter-child.js +provenance-manifest: .github/published-tags.json +latest-eligible: false +pre-tag: python3 tools/build-host-packages.py --check + diff --git a/.codearbiter/specs/release-portable-fixture.md b/.codearbiter/specs/release-portable-fixture.md index 04cd07f4..69800260 100644 --- a/.codearbiter/specs/release-portable-fixture.md +++ b/.codearbiter/specs/release-portable-fixture.md @@ -202,6 +202,32 @@ Grouped by slice; each is one `tdd` Phase 1 obligation and individually testable prose still enter the old-lane script by transcription, so this narrows the oracle problem rather than eliminating it. + **A second, sharper limit on the window variable.** `LAST_TAG`, manifest versions and artifacts + are genuinely independent between the two lanes. The *window commit set* is not: window + derivation has never lived in `_releaselib.py`, old or new — it is release-skill prose + (`git log LAST_TAG..HEAD`). So any straightforward trace shares one walk function across both + lanes, and the window comparison can only fail where `LAST_TAG` has already failed. It carries + no independent discriminating power **and no fixture change can give it any**, because a merge + commit or any other structure changes both sides identically. The window is therefore a + consistency check on the harness's own walk, not a behavior trace, and the criterion must not be + read as if it proves anything about the migration. + + *(Rev 4.6 corrects rev 4.5, which claimed a merge commit confers discriminating power. It does + not, for the reason above — that claim was written in from an unverified report and is the third + such error in this campaign. The fixture's merge commit does earn its place, but for a different + property: it catches a first-parent-only walk in the harness. The test docstring stated this + correctly while the spec stated the opposite.)* + +1.12 **The trace must state what it knowingly does NOT hold constant.** `last_tag_select` was + deliberately changed during this campaign: the pre-release marker test moved from the whole tag + to the prefix-stripped version portion, so a consumer prefix containing `-beta`/`-rc`/`-alpha` + no longer loses its entire series. That is an intended behavior change, and a trace asserting + "nothing changed" across it is asserting something false — it only reads as true because no + fixture prefix carries a marker. The fixture must include a marker-bearing prefix row and assert + the **intended** divergence explicitly (old lane yields the never-released sentinel, new lane + resolves the tag), converting the trace from "nothing changed" into "exactly this changed, on + purpose". + ### Slice 2 — pre-tag execution (check-only, DECISION-0034) 2.1 Declared commands execute in declared order. @@ -299,15 +325,59 @@ Grouped by slice; each is one `tdd` Phase 1 obligation and individually testable script the skill executes, since `check_adr_identity.py` is CI-only and is not shipped. 6.4 `commands/release.md` documents the same arguments and phase numbering as the skill. 6.5 The docs-site release guide distinguishes the general lane from this repo's configuration. -6.6 **Portability is proven in a clean consumer repo, not asserted.** A scratch git repo containing a - single `package.json`, a `CHANGELOG.md`, one `v*` tag, and an installed codeArbiter — with **no - file from this repository present** — runs `/ca:release` through target resolution, window - derivation, bump classification, and changelog rolling. *(Added rev 4.1: #563's acceptance carried - this as a checkbox but no numbered criterion existed, so no task covered it. Verifying against - this repo's own hand-built state is the documented way consumer-facing bugs stay hidden.)* -6.7 **This repo still releases.** After the migration, `/ca:release ca` reaches a composed tag on a - scratch branch with the same version the pre-change lane would have derived, and the tag is - discarded rather than published. +6.6 **Portability is proven in a clean consumer repo, not asserted.** The plugin is materialized by + `git archive HEAD -- plugins/ca` extracted into a scratch cache — **not** by pointing + `CLAUDE_PLUGIN_ROOT` at the in-repo tree, and **not** by a recursive copy. Both alternatives carry + dev-tree state: an in-repo pointer is the dev tree wearing a costume, and a copy brings + uncommitted and gitignored files, so a skill referencing a file that exists locally but was never + committed would still pass. `git archive` delivers exactly the committed payload. The scratch + consumer repo holds one `package.json`, a `CHANGELOG.md`, one `v*` tag, synthetic conventional + commits carrying `CHANGELOG:` footers, and **no file from this repository**. + + Three layers, with different reach: + + - **Reference resolution.** Every executed-or-read path in the *installed* `SKILL.md` resolves + against the scratch plugin root or scratch consumer repo. This catches the defect class that + started #563 with no model in the loop. It is distinct from AC-6.1's guard: the guard checks + the source lexically and misses a payload-packaging failure; this checks the installed result + physically. + - **Lane driver.** The mechanical sequence runs *as the prose spells it* — invoking the helper + CLI with **invocation strings extracted from the skill text**, never by direct import — through + target resolution, window derivation, bump classification, changelog rolling, and tag-message + composition. A drift between what the prose says to run and what the CLI accepts must fail + here rather than be papered over by a direct-import test. + - **Agent judgment (irreducible, and narrow).** Whether a model following the prose confirms the + bump, BLOCKs on a missing footer, STOPs without publication authorization, and handles the + back-fill confirmation honestly. Nothing mechanical proves prose-followability. This layer's + scope must be stated in its own docs so it is neither inflated nor skipped as already covered. + + Assertions are on **derived outputs** — the resolved row, `LAST_TAG`, the computed bump, the + rolled changelog text — never on exit codes alone. + + *Install-mechanism defects are explicitly out of reach here* — manifest parse errors, hook wiring + at install. Those belong to the clean-home install smoke test performed at release, and must not + be pulled into per-PR CI. + +6.7 **This repo still releases — proven without creating a ref.** The T-27 trace apparatus extends to + live-repo HEAD: the pinned pre-change lane and the new lane each derive the next version, the + commit window, and the composed tag **message file**, and the two must be equal. `notes-match` + and `dates-consistent` run against the composed message. **Zero refs are created.** + + *(Rev 4.4 — this replaces "compose a tag on a scratch branch and discard it". Two hazards killed + that: `payload_version_gate.py:115-117` checks local tags, so an interrupted proof leaves one + behind and every later gate run reports "already released"; and a single habitual + `git push --tags` publishes at the real next version with no `published-tags.json` entry, + tripping the immutability audit and forcing exactly the cleanup release doctrine calls a + deliberate, announced maintainer action. A tag's informative content is its message file and its + target derivation, both comparable without a ref. Real annotated-tag mechanics are exercised + inside the AC-6.6 scratch fixture, where refs are disposable by construction and the same code + path runs.)* + +6.8 **The agent-layer proof cannot rot silently.** The proof run records the content hash of the + shipped release skill it exercised. A declared `pre-tag` check asserts at release time that the + hash still matches the shipped skill, so editing the skill without re-running the proof **blocks + the next release**. Enforcement at the frequency the proof matters — per release, not per PR — + using this campaign's own `pre-tag` mechanism as the enforcement point. ## Decisions on record diff --git a/.codearbiter/sprint-log.md b/.codearbiter/sprint-log.md index 30e274a5..a15baa10 100644 --- a/.codearbiter/sprint-log.md +++ b/.codearbiter/sprint-log.md @@ -1216,3 +1216,5 @@ Started 2026-07-20T01:40:46-04:00. Append-only. SMARTS-scored auto-decisions; - [2026-07-31] REVIEW portable-release-and-protected-state B1 slice (T-01..T-05) | Opus adversarial pass per the maintainer's standard; 13-mutant campaign against the 9 new tests, 7 survivors, 2 HIGH | verdict: no live defect and no security hole — traversal safety, freshness delegation, genericity and zero-side-effects-at-import all survived attack, the latter verified under sys.addaudithook rather than assumed — but TEST ADEQUACY failed against the standing "every new test must die to a mutant" directive | HIGH-1: the default-registry code path has no fail-detecting test; mutating `registry = REGISTRY` to `registry = {}` passes all 9, because the only default-path test is a NEGATIVE assertion against one literal path and all three positive tests inject a synthetic registry — meaning nothing proves the production path works, and once consumers enrol at B-13/14/15 the entire guard could be a silent no-op with CI green | HIGH-2: the freshness window is a tautology in both directions; MARKER_FRESHNESS_MINUTES mutated to 1000 or 3 passes all 9, because both freshness tests compute their ages FROM the implementation constant, pinning boundary logic but not value — a 33x widening of a security window would ship green; compounded by a comment claiming the constant is "reused verbatim (not re-declared)" when 30 is in fact hardcoded in five independent places with no import relationship | ORCHESTRATOR ERROR, recorded deliberately: MEDIUM-3 established that my own earlier "fix" was wrong. I removed `REGISTRY: dict[str, ProtectedPolicy]` believing a PEP 585 builtin generic is evaluated at import and raises on Python 3.8, and reported that to the maintainer as a confirmed defect CI could not catch. The module carries `from __future__ import annotations` at line 84, which under PEP 563 leaves VARIABLE annotations unevaluated as well — verified by probe: the annotation with an undefined name raises nothing. The annotation was always safe on 3.8. Worse than the unnecessary change, the comment I left asserted "Every sibling _*lib.py declares module dicts bare for the same reason", a false rule that would have propagated into the next four modules of this campaign. Remediation restores the annotation and deletes the comment | remaining MEDIUMs sent for fix rather than filing, because all four propagate into the next four modules: a typo'd registry KEY fails open and silent (norm_path applied to the query, never the keys) which directly contradicts the module's own stated doctrine that malformed input must fail loud; the `str` mixin is entirely untested and its docstring's argv claim is false on 3.11+ where interpolation yields the class-qualified name and varies by interpreter version; `marker_name_for` is basename-only so two registered files sharing a stem share one authoring marker; and its norm_path call is untested | LOWs at discretion: dead `minutes` parameter, and the call-site constraint (module must be reached through classify_protected, never called directly by a flank) documented only in this log | confidence: high — every finding reproduced by executed mutation, not asserted. - [2026-07-31] SECURITY-GATE portable-release-and-protected-state H-22 flank wiring | security-reviewer verdict PASS, 0 CRITICAL 0 HIGH — the tripwire disposition therefore holds and autonomous execution continues without a maintainer stop | cleared by execution rather than by reading: the registry-is-code-constant property genuinely holds (REGISTRY assigned once as a literal, no json.load/open/env read anywhere in core/pysrc); the deferred import is necessary rather than stylistic and FAILS CLOSED (at call time it is a sys.modules hit since all three flanks already import the module at scope; a raise propagates into the except backstop and becomes block H-00 exit 2); every traced fail direction is correct (registry raise, unreadable marker dir, unresolvable root, cross-drive path, resolve returning None — all closed; genuinely-outside-repo correctly allows); and both load-bearing non-regressions are proved rather than accidental, with the regex-level cause pinned independently | the `touch` exclusion was CLEARED with evidence rather than accepted on precedent: touch cannot truncate on any supported platform, the PowerShell verbs that CAN truncate are all blocked, and every mtime consumer under .codearbiter/ was traced — the statusline key fails toward MORE work not less, staleness_warning does not key on registry paths, and marker_gated_write_admitted stats the MARKER not the protected file, so even `touch -t` back-dating cannot admit anything; the H-11 precedent that DOES include touch is principled (there, creation itself is the violation; here the helper creates the file legitimately) | THREE MEDIUMS, all latent while the registry is empty and all going live at B-13/B-14/B-15 — and those enrolment tasks will not re-run this review, which is the reviewer's argument for closing them inside this sprint rather than filing | M-1: lookup_policy matches by exact equality after separator normalization only, while its own docstring claims a `./` prefix or trailing slash still matches; measured, `./.codearbiter/open-tasks.md`, a trailing slash, a doubled slash and a leading space all MISS, so a mis-keyed entry protects nothing while LOOKING protected in manual testing because the shell flank keys off the bare basename and still fires | M-2, the more serious: the raw leg of the two-form lookup is INERT in the wired path because hosts always send absolute paths, so the state class rests entirely on repo_rel — which inverts symlink coverage exactly where the design claims to close #162; measured, when the protected path is itself a symlink the ABSOLUTE spelling (the only one a host sends) is ADMITTED while the relative one blocks, and the legacy regex classes are immune because search() matches the raw path regardless; the non-adversarial case is the concerning one, a consumer symlinking their board into a monorepo location loses H-22 silently with no diagnostic | M-3: the write-verb list is narrower than the H-05 precedent it cites as its model, leaving nine spellings allowed including `git checkout HEAD -- ` and `git restore` (closed for audit logs by LOG_GIT_RESTORE_RE under #335 with the rationale that they rewrite tracked files through Git itself, and all three planned entries are tracked), `sponge` (present in LOG_DESTROY_RE, dropped here), and `python -c`/`node -e` — the last directly contradicting B1's own stated premise for helper-only, that the sanctioned helper's Python file I/O is the ONLY route, since `python -c` naming the file lexically is exactly that; these are currently UNDECLARED residual rather than accepted residual | disposition: hold remediation until the parallel Opus adversarial pass lands, then remediate once against both reports rather than twice | confidence: high — every finding measured against a synthetic registry, not inferred. - [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-17..T-24 mechanism split | Opus adversarial pass: 46 mutants, 17 killed, 3 HIGH; all HIGH and 5 MEDIUM remediated, suite grew from 112 to 168 tests, sync-core byte-identical across 55 core files and 3 plugins | H-3 was the structurally interesting one: seven of the nine mechanism functions had ZERO coverage in the new module. Each could be replaced with a hardcoded constant return and all 112 tests stayed green, because the ~100 tests exercising those names ran against the OLD helper, the file this slice deliberately did not touch. The suite looked like it covered the migration and in fact covered the thing being migrated FROM. The reviewer then ran a ~1,900-input differential across both modules and found ZERO behavioral drift, so the split was correct all along, but that proof lived in a throwaway harness and nothing in the repo would have shown it. Right code, absent verification, which is the state that looks safest and is not | M-2 is the finding that justifies the campaign: the pre-release marker filter substring-tested "-beta", "-rc" and "-alpha" against the WHOLE TAG INCLUDING THE PREFIX. Unreachable in this repo because the anchored regex already excludes suffixed tags, which is why mutating it away survived. But a consumer whose prefix contains a marker loses every tag in that series and receives the "never released" sentinel, so the lane would offer a first-release baseline to a project that has releases. Verified fixed against the SHIPPED plugin copy: a web-beta-v prefix now resolves web-beta-v1.2.0 where it previously returned the sentinel, while a plain v prefix still correctly excludes v1.1.0-beta. A portability defect of exactly the class this split exists to remove, sitting inside the code doing the removing | H-1 silently truncated a block when a value contained the closing delimiter and it was the only close, parsing as success with a required key emptied, and separately misdiagnosed legitimate trailing prose; both directions fixed with a genuine-terminator scan | H-2: empty and whitespace-only values satisfied the required-key check because the row finisher tested "is None" while a parsed key always assigns a string, so a typo'd empty prefix became "" and flowed onward as "never released" | ORCHESTRATOR ERROR, second of this campaign: I wrote an unverified subagent claim into the spec as normative text, namely that a single-point mutant cannot kill the CRLF test. Measured, removing the raw-line strip alone DOES kill it, and for an incidental reason: with CRLF input the extracted block's first line is a bare carriage return which unstripped becomes a spurious key line and raises MalformedBlockError before the boolean assertion is reached. The dead-code half of the claim was true; the normative half was not, and it had become spec text instructing maintainers not to touch it. Corrected as rev 4.3. Same failure mode as the PEP 585 error earlier in this campaign: a specific, mechanically plausible claim arriving inside otherwise careful work, written down without the two-line probe that would have falsified it | also fixed: the missing-required-key error and the first-colon split rule were both untested, two escape hatches raised bare TypeError and FileNotFoundError outside the declared error hierarchy, a scalar-keys constant was dead code, and boolean parsing accepted TRUE against a spec requiring exactly true or false | confidence: high, every fix probed against the SHIPPED plugin copy rather than the source. +- [2026-07-31] PROXY-RULING portable-release-and-protected-state completion-proof design (T-73..T-76) | five rulings from the standing advisor under maintainer-proxy authorization, grounded in three of the maintainer's own recorded directives: verify against a clean install not the dev tree; verify by outputs not job status; execute the real path rather than asserting the contract. It ruled AGAINST my framing on Q4 and reframed Q5 | Q1, what "zero files from this repository" means: CHOSEN materialize the plugin from `git archive HEAD -- plugins/ca` extracted into a scratch cache, with the plugin root pointed there and the project dir at a scratch consumer repo. REJECTED pointing the plugin root at the in-repo tree, which is the dev tree wearing a costume and is the exact shape that masked consumer bugs before; ALSO rejected a plain recursive copy, which has the same defect at one remove because it carries uncommitted and gitignored files, so a skill referencing a file that exists locally but was never committed would still pass. Honest limit stated: the archive approach cannot catch install-mechanism defects such as manifest parse errors or hook wiring at install time; those belong to the clean-home install smoke test already performed at release, and must not be dragged into per-PR CI | Q2, CI or local: BOTH, split by what each can afford. The mechanical fixture costs single-digit seconds with no network, so it becomes a REQUIRED CI check whose assertions are on derived outputs (the resolved row, the last tag, the computed bump, the rolled changelog text) and never on exit codes alone. The agent-run layer cannot run without a model in the loop, so it stays local, and what stops it rotting is the ruling worth keeping: the proof run records the CONTENT HASH of the shipped release skill it exercised, and a declared pre-tag check asserts at release time that the hash still matches. Edit the skill without re-running the proof and the next release BLOCKS. That is enforcement at exactly the frequency the proof matters, and it enforces itself using the very pre-tag mechanism this campaign built | Q3, what can be proven without an agent: three layers, and the middle one is the substance. The library layer already exists and proves nothing about the lane. The lane-mechanics layer must be built and closes the gap I named: a reference-resolution test that extracts every executed-or-read path from the INSTALLED skill in the scratch environment and asserts each resolves, which catches the exact defect class that started #563 with no model involved; plus a lane driver that invokes the helper CLI using the invocation strings EXTRACTED FROM THE SKILL TEXT rather than by direct import, so a drift between what the prose says to run and what the CLI actually accepts fails the test instead of being papered over. The agent layer is genuinely irreducible but narrow: whether a model following the prose confirms the bump, blocks on a missing footer, stops without authorization, and handles the back-fill confirmation honestly. That scope must be stated plainly in the test docs so nobody inflates it or skips it as already covered | Q4, RULED AGAINST MY PLAN: no real tag in this repository, not even transiently. Two concrete hazards beyond doctrine. First, the payload version gate checks local tags, so an interrupted proof leaves a tag behind and every subsequent local gate run reports "already released", a self-inflicted footgun precisely where this campaign's credibility lives. Second, one habit-formed push publishes a tag at the real next version with no provenance entry, tripping the immutability audit and forcing the exact cleanup the release doctrine calls a deliberate announced maintainer action. And the delete step proves almost nothing, because a tag's informative content is its message file and its target derivation, both comparable without a ref. CHOSEN instead: extend the T-27 trace apparatus to live-repo HEAD, deriving next version, window and composed tag MESSAGE FILE from both the pinned pre-change lane and the new lane, asserting equality, with zero refs created. Real annotated-tag mechanics get exercised inside the scratch fixture, where refs are disposable by construction and the same code path runs | Q5, REFRAMED: build T-73 now, but as a RATCHET rather than a red test. Both of my framings were wrong. A long-red test cannot be a required check while red so it enforces nothing for weeks, and a test red for its whole life gets edited into passing on the day it finally matters, which is authored-at-the-end and shaped-to-pass by a slower route; but authoring at the end is strictly worse because the fixture's expected values get transcribed from the implementation they are supposed to check. The ratchet reconciles both: the reference-resolution assertion compares the unresolved set against a COMMITTED KNOWN-FAILURES LIST, so the test is green and required from day one and fails whenever that set changes in EITHER direction without the list being updated in the same diff, catching both a shrink someone forgot to record and a NEW contaminating reference sneaking in mid-campaign, which a plain red test would silently absorb. Each skill-rewrite task shrinks the list in its own reviewed commit; the final task asserts empty and deletes the ratchet in favor of the strict zero form | confidence: high, every ruling grounded in cited code or a recorded maintainer directive; none touches a non-delegable gate. +- [2026-07-31] REVIEW+REMEDIATION portable-release-and-protected-state T-25..T-27d shim, declared rows, resolution trace | Opus adversarial pass returned 4 HIGH and 6 MEDIUM, and falsified the slice's central claim: the shim DID break CI, in three independent places, two of them caught by the repo's own existing guards while I had reported the slice verified | HIGH-1: the shim broke test_release_workflow.py, 41 of 60 failing, because that suite builds a synthetic tree copying only the shim while the shim resolves the generated mechanism at import time; that suite is described in ci.yml as the ONLY guard on the release workflow's publish gates. Fixed in the TEST by materializing the mechanism into the synthetic tree, deliberately NOT in the shim, because a fallback tolerating a missing mechanism is the "fix it into a default" the spec forbids and would let a genuinely broken install pass | HIGH-2: test_release_trace.py, the campaign's entire proof of no behavior change, was referenced by no workflow and no sibling script, and the repo's own orphan guard was ALREADY RED naming it. A trace that certifies without executing is the worst state available. Now wired into the hooks job with fetch-depth 0 and fetch-tags, because the review reproduced both shallow-clone failures: the pinned SHA is unreachable without full depth and the live-tag assertion fails without tags. The remediating agent could not reproduce a genuine shallow clone locally, so rather than rely on an unverifiable assumption it added a preflight that HARD-FAILS with a named cause rather than skipping, which is the right instinct: a skip would have hidden exactly this | HIGH-3: the new shim test shelled out to the payload version gate with an origin/main base the hooks job checkout cannot resolve, so it would fail on every PR run, and it coupled the hooks job verdict to ca's payload-versus-version state. Replaced with an in-process identity assertion on the three names the real consumer imports, which is both git-free and a stronger proof | HIGH-4: ca-pi's declared rebuild command was not runnable as written, because build.mjs resolves inputs against cwd and needs the cd form the three sibling rows already used; as written it would have permanently blocked the ca-pi release lane the moment slice 3 lands, and a mutant replacing the command with a no-op survived the entire suite | MEDIUM highlights: the declared rows had almost no content coverage, 6 of 6 row mutants surviving including one that replaced a pre-tag command with a destructive rm, so nothing asserted this repo's own declared EXECUTABLE INPUT; and release-targets.md claimed in the present tense to be protected-write under H-22 while the registry is still empty and enrolment is T-33, so the file carrying operator-authored executable input sat on disk unprotected while documenting itself as protected | M-4 was the sharpest: the trace certified "changed nothing" across a function this campaign DELIBERATELY changed. last_tag_select's marker test moved from the whole tag to the prefix-stripped version, which was the consumer-portability fix two slices ago; on all four fixture prefixes old and new agree, so the trace read as true only because no fixture exercised the change. Now recorded as AC-1.12 with a marker-bearing fixture row asserting the INTENDED divergence, converting the trace from "nothing changed" into "exactly this changed, on purpose" | ORCHESTRATOR ERROR, third of this campaign: I wrote into the spec that adding a merge commit gives the window variable discriminating power. It does not, because both lanes call the same walk function so any fixture structure moves both sides identically. The test docstring said this correctly while my spec said the opposite. Corrected as rev 4.6, and I stopped claiming a remedy exists at all: the window is a consistency check on the harness, not a behavior trace. Same failure shape all three times, a specific mechanically plausible claim from careful subagent work written into a governing document without the probe that would falsify it | NEW FINDING filed as #568: the pre-release marker constant is unreachable dead code behind the anchored regex, and the module comment calling it a second line of defense was factually wrong as shipped. Probed and confirmed: the anchor rejects every suffixed tag, so no tag can both match the regex and carry a marker in its stripped version. This is fallout from the real portability fix rather than an original defect, since the marker test used to run against the whole tag which made it reachable AND wrong. Comment corrected in place; the delete-versus-relax-the-anchor question is the user's | final state: test_release_workflow 60 OK, test_ci_impact 54 OK, test_release_lib 188 OK, test_release_trace 19 OK and now CI-wired, hook suite green, sync-core byte-identical across 55 core files | confidence: high. diff --git a/.codearbiter/tech-stack.md b/.codearbiter/tech-stack.md index 52fbe5b5..07e58a94 100644 --- a/.codearbiter/tech-stack.md +++ b/.codearbiter/tech-stack.md @@ -64,6 +64,14 @@ python .github/scripts/test_taskwriter.py # publish classifier, the CLI, and the SKILL.md structural wiring (_releaselib) python .github/scripts/test_release_lib.py +# A-1.11 release resolution trace — proves the portable-mechanism split +# (core/pysrc/_releaselib.py + declared release-targets.md) reproduces the +# pinned pre-change lane's resolved variables for ca and ca-pi, and states +# the one intended divergence (AC-1.12, pre-release-marker scope). Needs +# FULL git history and all tags (fetch-depth: 0, fetch-tags: true) — its own +# preflight fails loudly, not silently, if either is missing. +python .github/scripts/test_release_trace.py + # commit-gate board-sync chokepoint — Phase 6 board-edit exemption + prose # wiring (AC-04..07, ADR-0008); structural SKILL.md/command-doc assertions python .github/scripts/test_board_sync.py diff --git a/.github/scripts/_releaselib.py b/.github/scripts/_releaselib.py index 746f2396..999e0506 100644 --- a/.github/scripts/_releaselib.py +++ b/.github/scripts/_releaselib.py @@ -1,133 +1,149 @@ #!/usr/bin/env python3 -# codeArbiter - pure helpers backing the `release` skill's mechanical gates. +# codeArbiter - CI's stable entry point for the release lane's mechanical gates +# (issue #563, .codearbiter/specs/release-portable-fixture.md, "Migration +# ordering"). # -# The release SKILL.md is prose the orchestrator follows; these are the small, -# git-free, synthetically-testable assertions that back its load-bearing steps so -# a model lapse can't ship a wrong or half-published release. Each maps to one -# acceptance criterion of specs/release-skill-hardening.md (findings v2.release. -# 0003-0006). The skill invokes them; CI runs their tests (test_release_lib.py). +# This file is the PERMANENT shim six sites shell out to directly -- +# release.yml:135,171 and .github/actions/publish-release/action.yml:125,164, +# 180,228 -- and payload_version_gate.py imports RELEASE_TAG_PREFIXES from it +# at module load. It is never deleted, only slimmed down: slice 1 (this +# change) converts it to a thin RE-EXPORT of the portable mechanism now living +# at core/pysrc/_releaselib.py, while TEMPORARILY retaining this repo's own +# data constants (RELEASE_TARGETS, RELEASE_TAG_PREFIXES, MERGE_READINESS_CHECK) +# so every existing caller keeps working unchanged. Slice 4 (T-46) removes the +# constants once payload_version_gate.py and release.yml read them from the +# declared .codearbiter/release-targets.md file instead; this shim survives +# that change too. +# +# The mechanism module is loaded from core/pysrc/ directly -- not from a +# vendored plugins/*/hooks/ copy -- because this file executes inside the +# codeArbiter repo itself (CI checks out the full repo), where core/pysrc/ is +# the canonical source `tools/sync-core.py` vendors FROM. It is located via +# this file's own __file__, never the process cwd, mirroring the +# cwd-independent REPO resolution `payload_version_gate.py` already uses +# (`Path(__file__).resolve().parents[2]`) -- so this module resolves +# correctly no matter what directory it is invoked from. Both this shim and +# the mechanism module are named `_releaselib.py`, so a plain `import +# _releaselib` from inside this file would resolve to itself via the module +# cache regardless of sys.path order; loading the mechanism by explicit file +# path under a distinct internal name (`importlib.util.spec_from_file_location`, +# the same technique `test_release_lib.py` already uses for the same reason) +# avoids that collision. +# +# KNOWN RESIDUAL (M-6, adversarial review 2026-07-31): the private module +# name above means this shim's copy of the mechanism is a DISTINCT load from +# any OTHER independent load of core/pysrc/_releaselib.py in the same +# process -- e.g. a test harness's own private-named copy, or a future +# second consumer that loads the mechanism itself rather than importing this +# shim. `shim.ReleaseTargetsError is .ReleaseTargetsError` is FALSE. An `except` clause spanning that +# boundary would miss. No caller does this today. This is documented rather +# than "fixed" by reusing a same-named entry out of `sys.modules` when one +# is present: there is no single canonical name a dynamically-loaded copy is +# guaranteed to register under (this file's own tests, `test_release_lib.py`, +# and `test_release_trace.py` each pick their own private name for their own +# reasons), so keying off `sys.modules` would make this shim's behavior +# depend on import order and on what some OTHER, unrelated test file +# happened to load first -- an import-order-dependent hazard traded for a +# documented, narrow one. The sanctioned route for a caller that needs to +# catch this shim's exceptions is to import the class from THIS shim (the +# public, re-exported surface), never to load its own separate copy of +# core/pysrc/_releaselib.py and expect the two hierarchies to unify. # # Design invariants (mirror the other _*lib helpers): -# - Stdlib only; zero side effects at import (no git, no file I/O). -# - Pure functions over synthetic input; never raise on malformed input - -# degrade to the safe answer (False / the sentinel), since a release -# gate that crashes is worse than one that conservatively refuses. +# - Stdlib only; zero side effects at import beyond loading the sibling +# mechanism module (no git, no argument parsing at import time). +# - The wrapper functions below restore this repo's OLD default arguments +# (ca's `v` prefix, RELEASE_TARGETS order, MERGE_READINESS_CHECK) so every +# existing caller -- the six shell-out sites, payload_version_gate.py, and +# this module's own CLI -- keeps working unchanged even though the +# portable mechanism's equivalents now REQUIRE the parameter that used to +# default (A-1.3: a repo default cannot survive in the portable module). # -# Public API: +# Public API (re-exported from core/pysrc/_releaselib.py, unwrapped -- no +# repo-specific default to restore): +# ReleaseTargetsError, AbsentBlockError, EmptyBlockError, MalformedBlockError, +# UnknownKeyError, DuplicateKeyError, DuplicateTargetError, +# InvalidBooleanError, MultipleBlocksError, DelimiterInValueError, +# MissingRequiredKeyError # semver_key(value) -> tuple | None # semver_greater(current, base) -> bool -# last_tag_select(tags) -> str # notes_heading_matches(notes_text, tag) -> bool # release_dates_consistent(changelog_section, tag_message) -> bool # classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, # manifest_version, release_is_nondraft) -> str -# select_release_target(*confirmations) -> str (RELEASE_TARGETS order) -# classify_merge_readiness(check_runs, head_sha, check_name) -> str # peel_tag(ls_remote_text, tag) -> str +# parse_release_targets(text) -> list[dict] +# load_targets(path) -> list[dict] +# _bare_version(tag) -> str +# NONE_SENTINEL +# +# Public API (this repo's DATA -- transitional, see module comment above): +# RELEASE_TARGETS, RELEASE_TAG_PREFIXES, MERGE_READINESS_CHECK +# last_tag_select(tags, prefix="v") -> str +# select_release_target(*confirmations) -> str (RELEASE_TARGETS order) +# classify_merge_readiness(check_runs, head_sha, check_name=MERGE_READINESS_CHECK) -> str # # The last three back `.github/workflows/release.yml`'s read-only preflight and # its tag-integrity guard (issues #378, #385, #380). The hosted publish path # holds `contents: write` and its writes are public and irreversible, so every # one of them degrades to the REFUSING answer on malformed input. -import re - -NONE_SENTINEL = "" - -# The `ci-passed` aggregate in .github/workflows/ci.yml — the single check run -# that means "every required job for this commit concluded green". Kept in sync -# with that job's `name:` by test_release_workflow.py. -MERGE_READINESS_CHECK = "[GATE ] | [REPO] | Merge readiness" - -# A `ca` release tag is exactly `vMAJOR.MINOR.PATCH` - no suffix. The anchored -# form already excludes pre-releases (`v2.6.0-beta.1`) and the namespaced -# `ca-sandbox-v*` series (no leading bare `v`); PRERELEASE_MARKERS is the -# explicit, legible second line of defense the spec names. -_RELEASE_RE_CACHE = {} - +import importlib.util +import os +import sys -def _release_re(prefix): - """The anchored `MAJOR.MINOR.PATCH` matcher for one release series.""" - rx = _RELEASE_RE_CACHE.get(prefix) - if rx is None: - rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$") - _RELEASE_RE_CACHE[prefix] = rx - return rx -_PRERELEASE_MARKERS = ("-beta", "-rc", "-alpha") +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_HERE)) +_MECHANISM_PATH = os.path.join(_REPO_ROOT, "core", "pysrc", "_releaselib.py") -# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the -# Keep-a-Changelog `## [X.Y.Z] - DATE` form the repo actually ships (every -# released section + every prior GitHub Release body uses the bracket style). -# The capture is the bare `X.Y.Z`; the optional leading `v` and the surrounding -# brackets sit OUTSIDE the group, so heading comparison is style-agnostic. Any -# separator is allowed between version and date. Plus the annotated-tag -# `Released-at:` footer. -_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE) -_CHANGELOG_DATE_RE = re.compile( - r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE) -_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})") +_mechanism_spec = importlib.util.spec_from_file_location( + "_release_mechanism", _MECHANISM_PATH) +_mechanism = importlib.util.module_from_spec(_mechanism_spec) +# Registered in sys.modules so introspection (inspect.getsourcefile, etc.) +# resolves the loaded module's real file rather than reading it back as a +# "built-in class" with no source at all. +sys.modules[_mechanism_spec.name] = _mechanism +_mechanism_spec.loader.exec_module(_mechanism) -# Full SemVer, including the pre-release and build-metadata tails a release tag -# never carries but a MANIFEST does (`0.2.4-beta.1` shipped on ca-codex). The -# anchored `_release_re` above deliberately rejects those, because it selects a -# published release series; this one parses a version for ORDERING, which is a -# different question and needs the tail. -SEMVER = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" -) - - -def semver_key(value): - """`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer. - - Non-raising per this module's invariant — a caller that needs the raising - contract wraps it (`tools/build-host-packages.py` does, to keep its own - diagnosis wording). Build metadata is parsed and discarded: SemVer §10 says - it is not part of precedence, so `1.0.0+a` and `1.0.0+b` compare equal. - """ - if not isinstance(value, str): - return None - match = SEMVER.fullmatch(value) - if match is None: - return None - prerelease = match.group(4) - if prerelease is None: - pre_key = None - else: - pre_key = tuple( - (0, int(part)) if part.isdigit() else (1, part) - for part in prerelease.split(".") - ) - return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key - - -def semver_greater(current, base): - """True iff `current` is a STRICT SemVer advance over `base`. +# --------------------------------------------------------------------------- # +# Re-exported mechanism -- unwrapped, no repo-specific default to restore. +# --------------------------------------------------------------------------- # - The single definition of "advance" for every payload-version gate (#530), so - `ca`, `ca-sandbox`, `ca-codex` and `ca-pi` cannot disagree about what - advancing means — issue #530 AC-3. +ReleaseTargetsError = _mechanism.ReleaseTargetsError +AbsentBlockError = _mechanism.AbsentBlockError +EmptyBlockError = _mechanism.EmptyBlockError +MalformedBlockError = _mechanism.MalformedBlockError +UnknownKeyError = _mechanism.UnknownKeyError +DuplicateKeyError = _mechanism.DuplicateKeyError +DuplicateTargetError = _mechanism.DuplicateTargetError +InvalidBooleanError = _mechanism.InvalidBooleanError +MultipleBlocksError = _mechanism.MultipleBlocksError +DelimiterInValueError = _mechanism.DelimiterInValueError +MissingRequiredKeyError = _mechanism.MissingRequiredKeyError + +semver_key = _mechanism.semver_key +semver_greater = _mechanism.semver_greater +notes_heading_matches = _mechanism.notes_heading_matches +release_dates_consistent = _mechanism.release_dates_consistent +classify_publish_state = _mechanism.classify_publish_state +peel_tag = _mechanism.peel_tag +parse_release_targets = _mechanism.parse_release_targets +load_targets = _mechanism.load_targets +_bare_version = _mechanism._bare_version +NONE_SENTINEL = _mechanism.NONE_SENTINEL - Degrades to False when either side is unparseable, which refuses the gate - rather than passing it. Pre-release ordering follows SemVer §11: a - pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric - identifiers compare numerically and rank below alphanumeric ones. - """ - current_key = semver_key(current) - base_key = semver_key(base) - if current_key is None or base_key is None: - return False - if current_key[:3] != base_key[:3]: - return current_key[:3] > base_key[:3] - current_pre, base_pre = current_key[3], base_key[3] - if current_pre is None: - return base_pre is not None - if base_pre is None: - return False - return current_pre > base_pre +# --------------------------------------------------------------------------- # +# DATA -- this repo's own facts. TRANSITIONAL (A-1.9): removed in slice 4 +# (T-46) once payload_version_gate.py and release.yml read +# .codearbiter/release-targets.md instead. Until then, no commit may leave +# RELEASE_TAG_PREFIXES unimportable from this module. +# --------------------------------------------------------------------------- # +# The `ci-passed` aggregate in .github/workflows/ci.yml - the single check run +# that means "every required job for this commit concluded green". Kept in sync +# with that job's `name:` by test_release_workflow.py. +MERGE_READINESS_CHECK = "[GATE ] | [REPO] | Merge readiness" # Every plugin that has a sanctioned release lane, in dispatch-input order # (#382). The names are the labels `select_release_target` returns and the @@ -139,8 +155,8 @@ def semver_greater(current, base): # Each target's tag namespace. `ca` owns the bare `v*` series as the repository's # primary release; every sibling is namespaced so it cannot collide with it. The # ANCHORED match built from these prefixes is also what keeps one series from -# resolving another's tag as its own baseline — `^v` cannot match `ca-pi-v0.1.30` -# — so series isolation is a property of the match rather than an exclusion list +# resolving another's tag as its own baseline - `^v` cannot match `ca-pi-v0.1.30` +# - so series isolation is a property of the match rather than an exclusion list # somebody has to remember to extend. release.yml's per-lane `tag-prefix` inputs # are asserted against this map by the workflow contract suite, so the hosted # lane and the /ca:release command cannot disagree about a namespace. @@ -152,123 +168,17 @@ def semver_greater(current, base): } -def _bare_version(tag): - """`v2.6.0` / `[2.6.0]` / `2.6.0` / `ca-pi-v0.1.31` -> the bare SemVer. - - Lets the heading match compare a tag against a bracket-style changelog - heading without caring about either spelling. - - This used to be `tag.lstrip("v")`, which strips only a LEADING "v" — right - for ca's bare `v2.9.1`, and wrong for every namespaced sibling, because - `"ca-pi-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31` - parsed out of the heading. The hosted publish action treats a failed - `notes-match` as a STOP, so the ca-codex, ca-sandbox and ca-pi lanes could - not have completed a release at all: they would have aborted at that guard - on a perfectly correct changelog, every time. Nothing caught it because the - lanes had never been dispatched and every test used a bare `v` tag. - - Anchored on the SemVer at the END rather than by stripping a known prefix, - so a fifth plugin's namespace works without being enumerated here.""" - if not isinstance(tag, str): - return tag - text = tag.strip().strip("[]") - match = re.search(r"(\d+\.\d+\.\d+.*)$", text) - return match.group(1) if match else text.lstrip("v") - - def last_tag_select(tags, prefix="v"): """Return the highest SemVer tag in `tags` for ONE release series, excluding pre-releases (`-beta`/`-rc`/`-alpha`). Returns NONE_SENTINEL when the series has no release tag yet. - `prefix` selects the series and defaults to `"v"` — ca, the primary release — - so every existing caller keeps its behaviour unchanged. Pass a value from - RELEASE_TAG_PREFIXES for a sibling (#382, the /ca:release command half). - - This is the single source of `LAST_TAG`, replacing the skill's inline grep - one-liner: bare `git describe --tags` returns the nearest tag by commit-graph - ANCESTRY, which in a multi-plugin repo is routinely another plugin's tag, and - silently bases an entire release on the wrong baseline. - - Series isolation is a property of the ANCHORED match rather than a list of - exclusions to maintain: `^v` cannot match `ca-pi-v0.1.30`, and `^ca-pi-v` - cannot match `v2.9.1`. A fifth plugin therefore cannot leak into an existing - series by being forgotten in an exclusion list.""" - best = None # ((major, minor, patch), original_tag) - if not isinstance(tags, (list, tuple)): - return NONE_SENTINEL - if not isinstance(prefix, str) or not prefix: - return NONE_SENTINEL - matcher = _release_re(prefix) - for t in tags: - if not isinstance(t, str): - continue - if any(marker in t for marker in _PRERELEASE_MARKERS): - continue - m = matcher.match(t) - if not m: - continue - ver = tuple(int(g) for g in m.groups()) - if best is None or ver > best[0]: - best = (ver, t) - return best[1] if best else NONE_SENTINEL - - -def notes_heading_matches(notes_text, tag): - """True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z` - or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as `tag`. A - stale notes-file (whose first section is an older version) returns False, so - the release skill cannot publish the wrong changelog section under the right - tag. Missing heading or non-string input -> False.""" - if not isinstance(notes_text, str) or not isinstance(tag, str): - return False - m = _HEADING_RE.search(notes_text) - if not m: - return False - return m.group(1) == _bare_version(tag) - - -def release_dates_consistent(changelog_section, tag_message): - """True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE` or - `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in `tag_message`. - Guards against the date being - hand-typed inconsistently across surfaces. Either date absent, or non-string - input -> False.""" - if not isinstance(changelog_section, str) or not isinstance(tag_message, str): - return False - cm = _CHANGELOG_DATE_RE.search(changelog_section) - tm = _RELEASED_AT_RE.search(tag_message) - if not cm or not tm: - return False - return cm.group(1) == tm.group(1) - - -def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version, - manifest_version, release_is_nondraft): - """Classify a (re)publish attempt so the skill can resume a half-finished - publish instead of dead-ending on 'tag exists -> STOP'. Returns one of: - - publish_fresh - no tag yet; the normal Phase 2/3 path. - already_published - the tag is at HEAD and a non-draft Release exists. - resume_publish - tag is at HEAD and its version matches the manifest, - but no non-draft Release exists (tag pushed, Release - never created) -> finish Phase 3. - abort_mismatch - tag points at a non-HEAD commit, or its version - disagrees with the manifest -> STOP, never overwrite. - - Mismatch OUTRANKS publication state (issue #380). An existing Release used - to short-circuit to `already_published` before the tag was compared to - HEAD, so a resumed publish silently accepted a Release whose tag installs a - different snapshot. The tag is what consumers actually fetch; if it does - not name this commit, nothing about the Release makes the state safe. - """ - if not tag_exists: - return "publish_fresh" - if tag_sha != head_sha or tag_version != manifest_version: - return "abort_mismatch" - if release_is_nondraft: - return "already_published" - return "resume_publish" + `prefix` defaults to `"v"` - ca, the primary release - so every existing + caller (the CLI's bare `last-tag` invocation, this repo's tooling) keeps its + behaviour unchanged even though the portable mechanism's own + `last_tag_select` now REQUIRES the prefix (A-1.3). Pass a value from + RELEASE_TAG_PREFIXES for a sibling (#382).""" + return _mechanism.last_tag_select(tags, prefix) def select_release_target(*confirmations): @@ -281,33 +191,10 @@ def select_release_target(*confirmations): multiple - more than one; the dispatch is ambiguous and MUST be refused. arity - the caller passed the wrong NUMBER of inputs. - Issue #378: the publish jobs each tested only their OWN confirmation input, - so one dispatch supplying both started two `contents: write` publishers and - could create two tags and two public Releases. Selection is one decision, - made once, by a job that holds no write token. Blank-ish input (whitespace, - non-string) counts as "not selected" so a stray space can never read as a - second target. - - Issue #382 widened this from two plugins to four (ca, ca-codex, ca-sandbox, - ca-pi). The count is checked rather than zipped-to-shortest on purpose: a - caller wired for two would otherwise resolve `ca` from a dispatch that also - selected ca-pi, silently publishing the wrong plugin. `arity` is not a - target and matches no `case` arm in release.yml, so the workflow's - fail-closed `*)` default refuses it - and, like every other return here, - it is a LABEL rather than an exception, so the caller's contract of "prints - a label and never raises" holds.""" - def _selected(value): - return isinstance(value, str) and value.strip() != "" - - if len(confirmations) != len(RELEASE_TARGETS): - return "arity" - selected = [target for target, value in zip(RELEASE_TARGETS, confirmations) - if _selected(value)] - if len(selected) > 1: - return "multiple" - if selected: - return selected[0] - return "none" + Delegates to the portable mechanism's `select_release_target`, supplying + this repo's RELEASE_TARGETS register - which the portable module now + REQUIRES rather than assumes (A-1.3).""" + return _mechanism.select_release_target(*confirmations, targets=RELEASE_TARGETS) def classify_merge_readiness(check_runs, head_sha, check_name=MERGE_READINESS_CHECK): @@ -322,59 +209,17 @@ def classify_merge_readiness(check_runs, head_sha, check_name=MERGE_READINESS_CH not_successful - completed with any conclusion other than `success` (failure, cancelled, skipped, timed_out, neutral, ...). - Issue #385: the hosted release workflow proved only that it was dispatched - from main. Branch protection shows how a commit ENTERED main, not that - post-merge evidence exists for the exact commit about to be tagged, and the - release skill's hard rules say MUST NOT tag on a red suite. - - Fail-closed throughout: unparseable input is `missing`, and several runs - share one name only when a re-run is in flight - we cannot tell which - verdict is authoritative, so EVERY matching run must be green.""" - if not isinstance(check_runs, list): - return "missing" - matching = [run for run in check_runs - if isinstance(run, dict) and run.get("name") == check_name] - if not matching: - return "missing" - if any(run.get("head_sha") != head_sha for run in matching): - return "sha_mismatch" - if any(run.get("status") != "completed" for run in matching): - return "pending" - if any(run.get("conclusion") != "success" for run in matching): - return "not_successful" - return "green" - - -def peel_tag(ls_remote_text, tag): - """Resolve the COMMIT a remote tag names, from `git ls-remote --tags` - output. Returns "" when the tag is absent. - - An annotated tag's own object id is not the commit it points at; the - peeled `refs/tags/^{}` line is. Issue #380: the workflow treated any - remote hit as a resumable publish and skipped tag creation without ever - comparing the tag to `GITHUB_SHA`, so a stale tag could be accepted as a - successful rerun and a Release published for the wrong commit. Matching is - exact on the ref name, so `v2.6.0` is never resolved from `v2.6.0-beta.1`.""" - if not isinstance(ls_remote_text, str) or not isinstance(tag, str): - return "" - direct = peeled = "" - ref = f"refs/tags/{tag}" - for line in ls_remote_text.splitlines(): - parts = line.split() - if len(parts) != 2: - continue - sha, name = parts - if name == ref + "^{}": - peeled = sha - elif name == ref: - direct = sha - return peeled or direct + `check_name` defaults to MERGE_READINESS_CHECK so every existing caller + keeps its behaviour unchanged even though the portable mechanism's own + `classify_merge_readiness` now REQUIRES it (A-1.3).""" + return _mechanism.classify_merge_readiness(check_runs, head_sha, check_name) # --------------------------------------------------------------------------- # -# Thin CLI so the release SKILL.md can shell out to the pinned logic, the same -# way it already calls check_badge_consistency.py. Pure dispatch over the -# functions above; reads tags from stdin / files from argv. Never raises. +# Thin CLI so release.yml / publish-release/action.yml can shell out to the +# pinned logic, the same way they already call check_badge_consistency.py. +# Pure dispatch over the functions above; reads tags from stdin / files from +# argv. Never raises. # --------------------------------------------------------------------------- # def _read(path): diff --git a/.github/scripts/fixtures/release-trace/commit-graph.json b/.github/scripts/fixtures/release-trace/commit-graph.json new file mode 100644 index 00000000..d8360cc8 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/commit-graph.json @@ -0,0 +1,39 @@ +{ + "$comment": [ + "A-1.11 (T-27a) resolution-trace fixture: a small, frozen, SYNTHETIC commit", + "graph -- never a real git repository. `head` names the commit HEAD points", + "at; `commits` is the parent adjacency a plain DFS/BFS walks to answer", + "'git log ..HEAD'; `tag_refs` maps each tag in tags.txt to the sha it", + "names, exactly like `git ls-remote --tags` would. Values are synthetic", + "sha-shaped strings (c1..c6, b1), not real object ids -- nothing here is", + "ever written to an actual .git directory.", + "", + "`b1` branches off `c4` and merges into `c6` alongside `c5` (`c6`'s second", + "parent) precisely so WINDOW_COMMITS is not a straight line: a walk that", + "only follows each commit's FIRST parent (a plausible wrong", + "implementation of 'git log ..HEAD') silently drops `b1` from every", + "window that should contain it, where an all-parents walk does not. See", + "the module docstring's WINDOW_COMMITS honest-limit paragraph and", + "WindowCommitsTest." + ], + "head": "c6", + "commits": [ + {"sha": "c1", "parents": []}, + {"sha": "c2", "parents": ["c1"]}, + {"sha": "c3", "parents": ["c2"]}, + {"sha": "c4", "parents": ["c3"]}, + {"sha": "c5", "parents": ["c4"]}, + {"sha": "b1", "parents": ["c4"]}, + {"sha": "c6", "parents": ["c5", "b1"]} + ], + "tag_refs": { + "v1.0.0": "c2", + "v1.1.0": "c4", + "v1.1.0-beta.1": "c3", + "ca-pi-v0.1.0": "c2", + "ca-pi-v0.1.1": "c5", + "ca-codex-v0.2.0": "c1", + "ca-sandbox-v0.1.0": "c1", + "web-beta-v1.0.0": "c6" + } +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json b/.github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json new file mode 100644 index 00000000..fb2453a9 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-codex-plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ca-codex", + "version": "0.2.0" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-pi-package.json b/.github/scripts/fixtures/release-trace/manifests/ca-pi-package.json new file mode 100644 index 00000000..167be803 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-pi-package.json @@ -0,0 +1,4 @@ +{ + "name": "ca-pi", + "version": "0.1.2" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json b/.github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json new file mode 100644 index 00000000..96e99ec9 --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-pi-root-package.json @@ -0,0 +1,4 @@ +{ + "name": "codearbiter", + "version": "0.1.3" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-plugin.json b/.github/scripts/fixtures/release-trace/manifests/ca-plugin.json new file mode 100644 index 00000000..a9d8b10a --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ca", + "version": "1.2.0" +} diff --git a/.github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json b/.github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json new file mode 100644 index 00000000..32ea4f8b --- /dev/null +++ b/.github/scripts/fixtures/release-trace/manifests/ca-sandbox-plugin.json @@ -0,0 +1,4 @@ +{ + "name": "ca-sandbox", + "version": "0.1.0" +} diff --git a/.github/scripts/fixtures/release-trace/release-targets.md b/.github/scripts/fixtures/release-trace/release-targets.md new file mode 100644 index 00000000..d037628e --- /dev/null +++ b/.github/scripts/fixtures/release-trace/release-targets.md @@ -0,0 +1,48 @@ +# Resolution-trace fixture — frozen declared-target rows + +A-1.11 (T-27a): a FROZEN, self-contained copy in the declared-target grammar +— deliberately separate from this repository's own +`.codearbiter/release-targets.md`, so the trace stays stable even if that +file changes shape later. Paths are relative to this fixture directory, not +the repository root. + + +[ca] +prefix: v +manifest: manifests/ca-plugin.json +changelog: CHANGELOG.md +payload: ca/ +artifacts: ca/tools/farm.js +provenance-manifest: published-tags.json +latest-eligible: true + +[ca-codex] +prefix: ca-codex-v +manifest: manifests/ca-codex-plugin.json +changelog: ca-codex/CHANGELOG.md +payload: ca-codex/ +provenance-manifest: published-tags.json +latest-eligible: false + +[ca-sandbox] +prefix: ca-sandbox-v +manifest: manifests/ca-sandbox-plugin.json +changelog: ca-sandbox/CHANGELOG.md +payload: ca-sandbox/ +artifacts: ca-sandbox/tools/sandbox.js +artifacts: ca-sandbox/tools/claude-inside.js +provenance-manifest: published-tags.json +latest-eligible: false + +[ca-pi] +prefix: ca-pi-v +manifest: manifests/ca-pi-package.json +manifest: manifests/ca-pi-root-package.json +changelog: ca-pi/CHANGELOG.md +payload: ca-pi/ +payload-exclude: ca-pi/tools/ +artifacts: ca-pi/extensions/codearbiter.js +artifacts: ca-pi/extensions/codearbiter-child.js +provenance-manifest: published-tags.json +latest-eligible: false + diff --git a/.github/scripts/fixtures/release-trace/tags.txt b/.github/scripts/fixtures/release-trace/tags.txt new file mode 100644 index 00000000..6166ac0b --- /dev/null +++ b/.github/scripts/fixtures/release-trace/tags.txt @@ -0,0 +1,8 @@ +v1.0.0 +v1.1.0 +v1.1.0-beta.1 +ca-pi-v0.1.0 +ca-pi-v0.1.1 +ca-codex-v0.2.0 +ca-sandbox-v0.1.0 +web-beta-v1.0.0 diff --git a/.github/scripts/test_release_lib.py b/.github/scripts/test_release_lib.py index d54e5bc2..d2905e09 100644 --- a/.github/scripts/test_release_lib.py +++ b/.github/scripts/test_release_lib.py @@ -47,6 +47,7 @@ """ import importlib.util +import inspect import os import sys import unittest @@ -61,6 +62,11 @@ _core_spec = importlib.util.spec_from_file_location( "_core_releaselib", _CORE_RELEASELIB_PATH) core_releaselib = importlib.util.module_from_spec(_core_spec) +# Registered in sys.modules (not just bound to a local name) so `inspect. +# getsourcefile` can resolve it — inspect looks the object's `__module__` up +# in sys.modules to find `__file__`, and an unregistered dynamically-loaded +# module reads back as a "built-in class" with no source file at all. +sys.modules[_core_spec.name] = core_releaselib _core_spec.loader.exec_module(core_releaselib) @@ -769,6 +775,159 @@ def test_bare_version_extracts_the_semver_from_any_spelling(self): with self.subTest(spelling=spelling): self.assertEqual(_releaselib._bare_version(spelling), want) + +class ReleaselibShimTest(unittest.TestCase): + """A-1.9 (transitional, retired at T-46 alongside AC-4.4): the shim + (`.github/scripts/_releaselib.py`, imported above as `_releaselib`) must + be a thin RE-EXPORT of the portable mechanism (`core_releaselib`), not a + second implementation, while still exposing this repo's own data + constants so the six CI shell-out sites and `payload_version_gate.py` + keep working unchanged. See the migration ordering in + .codearbiter/specs/release-portable-fixture.md: until slice 4 lands, no + commit may leave RELEASE_TAG_PREFIXES unimportable from this module.""" + + def test_releaselib_shim_exports_constants(self): + # payload_version_gate.py:53 imports exactly these three names from + # this shim at module load; they must still resolve. + self.assertEqual(_releaselib.RELEASE_TAG_PREFIXES, + {"ca": "v", "ca-codex": "ca-codex-v", + "ca-sandbox": "ca-sandbox-v", "ca-pi": "ca-pi-v"}) + self.assertTrue(callable(_releaselib.semver_greater)) + self.assertTrue(callable(_releaselib.semver_key)) + + def test_shim_error_hierarchy_is_the_portable_modules_not_a_duplicate(self): + # A duplicate hierarchy would let the two modules' exceptions + # silently diverge: a caller catching the shim's ReleaseTargetsError + # would not catch one raised by core/pysrc/_releaselib.py, and vice + # versa. `inspect.getsourcefile` -- rather than `assertIs` -- is the + # right proof here: the shim loads its own copy of the mechanism + # module under a private name (`_release_mechanism`), so its classes + # are never the SAME objects as this test's own independently-loaded + # `core_releaselib` (a second, distinct exec of the same file). + # Source-file identity is what "re-exported, not reimplemented" + # actually means; a hand-written duplicate hierarchy inside the shim + # itself would report the SHIM's own path here, not the core one. + self.assertEqual( + inspect.getsourcefile(_releaselib.AbsentBlockError), _CORE_RELEASELIB_PATH) + self.assertEqual( + inspect.getsourcefile(_releaselib.ReleaseTargetsError), _CORE_RELEASELIB_PATH) + self.assertEqual( + inspect.getsourcefile(_releaselib.MissingRequiredKeyError), + _CORE_RELEASELIB_PATH) + + # M-3 (adversarial review, 2026-07-31): the 3-name spot-check above + # proves PROVENANCE (the class comes from the core file) but not + # IDENTITY (it is the RIGHT class from that file) -- a swap bug like + # `AbsentBlockError = _mechanism.MalformedBlockError` inside the shim + # would still pass every assertion above unchanged, since both classes + # live in the same core file. Enumerated explicitly, all 11 names. + _EXCEPTION_NAMES = ( + "ReleaseTargetsError", "AbsentBlockError", "EmptyBlockError", + "MalformedBlockError", "UnknownKeyError", "DuplicateKeyError", + "DuplicateTargetError", "InvalidBooleanError", "MultipleBlocksError", + "DelimiterInValueError", "MissingRequiredKeyError", + ) + + def test_shim_exception_names_are_not_silently_swapped(self): + # `__name__` is set at CLASS-DEFINITION time in the core module and + # is independent of whichever shim-level variable currently holds a + # reference to it -- so a swap (the shim name binds to the WRONG + # core class) shows up here even though `inspect.getsourcefile` + # alone cannot see it (both classes share one file). Paired with the + # existing source-file check and `issubclass`, this closes both + # directions the review named: a same-file swap (caught by + # `__name__`) and a hand-written duplicate under the right name + # (caught by `getsourcefile`). + for name in self._EXCEPTION_NAMES: + with self.subTest(name=name): + cls = getattr(_releaselib, name) + self.assertEqual(cls.__name__, name) + self.assertEqual(inspect.getsourcefile(cls), _CORE_RELEASELIB_PATH) + self.assertTrue(issubclass(cls, _releaselib.ReleaseTargetsError)) + + def test_shim_mechanism_functions_are_the_portable_modules_not_a_duplicate(self): + # Same reasoning as the error-hierarchy test above, applied to every + # non-defaulted mechanism function: each must be DEFINED in + # core/pysrc/_releaselib.py, never reimplemented in the shim. (The + # three wrapped functions -- last_tag_select, select_release_target, + # classify_merge_readiness -- restore this repo's OLD default + # arguments and so are legitimately NEW, thin functions defined in + # the shim itself; they are covered by the wrapper test below, not + # here.) + for fn in (_releaselib.semver_key, _releaselib.semver_greater, + _releaselib.notes_heading_matches, + _releaselib.release_dates_consistent, + _releaselib.classify_publish_state, _releaselib.peel_tag, + _releaselib.load_targets, _releaselib.parse_release_targets): + with self.subTest(fn=fn.__name__): + self.assertEqual(inspect.getsourcefile(fn), _CORE_RELEASELIB_PATH) + + def test_shim_wrapped_functions_restore_the_old_default_arguments(self): + # last_tag_select, select_release_target, and classify_merge_readiness + # now REQUIRE, in the portable module, the argument that used to + # default (A-1.3). The shim's own wrappers must still work with no + # argument, exactly as every existing caller invokes them. + self.assertEqual(_releaselib.last_tag_select(["v1.0.0"]), "v1.0.0") + self.assertEqual( + _releaselib.select_release_target("1.0.0", "", "", ""), "ca") + self.assertEqual( + _releaselib.classify_merge_readiness( + [{"name": _releaselib.MERGE_READINESS_CHECK, "head_sha": "a" * 40, + "status": "completed", "conclusion": "success"}], "a" * 40), + "green") + + def test_shim_resolves_the_mechanism_regardless_of_cwd(self): + # The whole point of locating the mechanism via this file's own + # __file__ rather than the process cwd (mirroring + # payload_version_gate.py's own REPO resolution): invoking the CLI + # from a directory with no relationship to this repo must not break + # the import. A cwd-dependent relative lookup would fail here. + import subprocess + import tempfile + with tempfile.TemporaryDirectory() as elsewhere: + result = subprocess.run( + [sys.executable, os.path.join(HERE, "_releaselib.py"), "last-tag"], + cwd=elsewhere, input="v1.0.0\nv1.1.0\n", + capture_output=True, text=True, timeout=30) + self.assertEqual((result.returncode, result.stdout.strip()), (0, "v1.1.0")) + + def test_payload_version_gate_imports_and_runs_unchanged(self): + # The load-bearing consumer (payload_version_gate.py:53) does + # `from _releaselib import RELEASE_TAG_PREFIXES, semver_greater, + # semver_key` at module load. This used to shell out to the gate CLI + # with `--base origin/main`, but the hooks job's checkout has no + # `fetch-depth: 0` and never fetches `origin/main`, so that + # subprocess correctly returned FAIL (exit 1) there for a reason that + # has nothing to do with this shim (HIGH-3). It also duplicated the + # `version-bump-ca` gate's verdict under a different trigger set, + # coupling this shim's own test to this repo's release/payload state. + # + # A plain in-process `import` exercises the EXACT statement the + # consumer runs, in the same interpreter this test file is already + # running in (`sys.path.insert(0, HERE)` above puts `.github/scripts` + # first, exactly where `payload_version_gate.py` itself inserts it). + # `from … import` binds names to the SAME objects the module-level + # `_releaselib` import at the top of this file already produced, so + # `assertIs` is a genuine identity proof of the shim/consumer wiring + # — no git, no network, no repo release-state dependency at all. + import payload_version_gate # noqa: E402 — needs sys.path mutation above + self.assertIs(payload_version_gate.RELEASE_TAG_PREFIXES, + _releaselib.RELEASE_TAG_PREFIXES) + self.assertIs(payload_version_gate.semver_greater, _releaselib.semver_greater) + self.assertIs(payload_version_gate.semver_key, _releaselib.semver_key) + + def test_bare_invocation_exits_2(self): + # Argparse's own required-argument failure, not a crash -- the args + # are required, so a bare invocation must refuse rather than guess. + # In-process (`main([])` raises SystemExit before any git call is + # made, so this never depended on repo state) rather than a 120s- + # timeout subprocess with its own cwd dependency. + import payload_version_gate + with self.assertRaises(SystemExit) as ctx: + payload_version_gate.main([]) + self.assertEqual(ctx.exception.code, 2) + + class DenylistTest(unittest.TestCase): """A-1.2: the portable mechanism (core/pysrc/_releaselib.py) must carry no literal from this repository's namespace or CI vocabulary. `.github/ @@ -1275,6 +1434,42 @@ def test_a_prefix_containing_alpha_still_resolves_its_own_releases(self): core_releaselib.last_tag_select(tags, "thing-alpha-v"), "thing-alpha-v0.2.0") +class CorePrereleaseMarkersSecondLineOfDefenseTest(unittest.TestCase): + """Adversarial-review remediation (M-4, 2026-07-31): `_PRERELEASE_MARKERS` + is documented in `core/pysrc/_releaselib.py` as an explicit SECOND line of + defense behind the anchored `_release_re` matcher — but `_release_re`'s + own trailing `$` already rejects any tag carrying a suffix past + `MAJOR.MINOR.PATCH`, so a marker-bearing tag never reaches the + `_PRERELEASE_MARKERS` check at all through the public `last_tag_select` + API today. Mutating `_PRERELEASE_MARKERS` to `()` therefore changes + NOTHING observable through that path — confirmed empirically, and + exactly what the review found ("survives both suites"). + + A bare `assertEqual(_PRERELEASE_MARKERS, (...))` would die to that + mutant too, but would prove only that the tuple's literal value is + pinned, not that the defense DOES anything — the constant could be + renamed to nonsense words and that test would still only check its own + copy of the value. This test instead exercises the second line of + defense in the scenario it exists FOR: a hypothetical relaxation of the + first line. It monkeypatches `_release_re` to an UNANCHORED matcher (no + trailing `$`) for the duration of one call, so a marker-bearing "version" + now clears the (weakened) regex and reaches the `_PRERELEASE_MARKERS` + check on its own merits — the only way to make this line reachable + without weakening the shipped regex itself, which is out of scope here. + """ + + def test_marker_check_still_excludes_when_the_regex_alone_would_not(self): + import re as _re + import unittest.mock as mock + lax = _re.compile(r"^v(\d+)\.(\d+)\.(\d+)") # deliberately no trailing $ + with mock.patch.object(core_releaselib, "_release_re", return_value=lax): + tags = ["v1.0.0", "v1.1.0-beta.1"] + # Under the lax regex alone, "v1.1.0-beta.1" matches (span + # v1.1.0) and is numerically higher than v1.0.0 — only + # _PRERELEASE_MARKERS can still exclude it here. + self.assertEqual(core_releaselib.last_tag_select(tags, "v"), "v1.0.0") + + class CoreColonInValueTest(unittest.TestCase): """M4: values split on the FIRST colon only; a later colon is part of the value. `line.find(":")` vs `line.rfind(":")` are indistinguishable @@ -1561,5 +1756,202 @@ def test_the_matching_position_selects_its_target(self): "lib") +class ThisRepoRowsTest(unittest.TestCase): + """A-1.10 (T-26): this repository's own `.codearbiter/release-targets.md` + loads as four rows (`this_repo_rows`) whose `target` and `prefix` equal + the pre-change RELEASE_TAG_PREFIXES constants (the shim's + temporarily-retained data, A-1.9), and every one of the four declares + `provenance-manifest` — a maintainer decision (2026-07-31) tighter than + the spec's own grammar example, which shows the field on `ca` alone. An + absent field silently skips the tag-provenance recording step (A-3.5), so + three of four release lanes would otherwise stop recording published tags + with no signal at all — a behavior change this migration must not + introduce. Every method name below carries `this_repo_rows` so `-k + this_repo_rows` (the verification command named in the plan) selects the + whole class.""" + + REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) + TARGETS_PATH = os.path.join(REPO_ROOT, ".codearbiter", "release-targets.md") + + @classmethod + def setUpClass(cls): + cls.rows = core_releaselib.load_targets(cls.TARGETS_PATH) + cls.by_target = {row["target"]: row for row in cls.rows} + + def test_this_repo_rows_exactly_four_load(self): + self.assertEqual(len(self.rows), 4) + self.assertEqual(set(self.by_target), {"ca", "ca-codex", "ca-sandbox", "ca-pi"}) + + def test_this_repo_rows_target_and_prefix_equal_the_pre_change_constants(self): + # _releaselib.RELEASE_TAG_PREFIXES is this repo's own pre-change + # constant (still carried by the shim, A-1.9); the declared file must + # agree with it exactly, in both directions. + for target, prefix in _releaselib.RELEASE_TAG_PREFIXES.items(): + with self.subTest(target=target): + self.assertIn(target, self.by_target) + self.assertEqual(self.by_target[target]["prefix"], prefix) + self.assertEqual( + {row["target"] for row in self.rows}, set(_releaselib.RELEASE_TARGETS)) + + def test_this_repo_rows_every_row_declares_a_provenance_manifest(self): + for target, row in self.by_target.items(): + with self.subTest(target=target): + self.assertEqual(row["provenance_manifest"], ".github/published-tags.json") + + def test_this_repo_rows_only_ca_is_latest_eligible(self): + for target, row in self.by_target.items(): + with self.subTest(target=target): + self.assertEqual(row["latest_eligible"], target == "ca") + + def test_this_repo_rows_ca_pi_declares_two_manifests_and_excludes_tools(self): + row = self.by_target["ca-pi"] + self.assertEqual( + row["manifest"], ["plugins/ca-pi/package.json", "package.json"]) + self.assertEqual(row["payload_exclude"], ["plugins/ca-pi/tools/"]) + + def test_this_repo_rows_every_manifest_and_changelog_path_exists_on_disk(self): + for target, row in self.by_target.items(): + for rel in [row["changelog"], *row["manifest"]]: + with self.subTest(target=target, path=rel): + self.assertTrue( + os.path.isfile(os.path.join(self.REPO_ROOT, rel)), + f"{target}: declared path {rel!r} does not exist on disk") + + # -- M-2 (adversarial review, 2026-07-31): "6 of 6 row mutants survived, + # including replacing ca-pi's pre-tag with `rm -rf plugins/`, pointing + # payload at the wrong plugin, and deleting an artifact line." The + # methods below assert this repo's declared executable input by exact + # content, not merely by existence, closing each named mutant. -- + + # Sourced from plugins/ca/skills/release/SKILL.md's own Targets table + # (the authoritative source .codearbiter/release-targets.md transcribes) + # -- an EXACT list per target, so a deleted or reordered artifact line + # cannot survive: "each artifacts path exists" alone does not catch a + # deletion, since every surviving path in a shortened list still exists. + _EXPECTED_ARTIFACTS = { + "ca": ["plugins/ca/tools/farm.js"], + "ca-codex": [], + "ca-sandbox": [ + "plugins/ca-sandbox/tools/sandbox.js", + "plugins/ca-sandbox/tools/claude-inside.js", + ], + "ca-pi": [ + "plugins/ca-pi/extensions/codearbiter.js", + "plugins/ca-pi/extensions/codearbiter-child.js", + ], + } + + def test_this_repo_rows_artifacts_exactly_match_the_declared_set(self): + for target, expected in self._EXPECTED_ARTIFACTS.items(): + with self.subTest(target=target): + self.assertEqual(self.by_target[target]["artifacts"], expected) + + def test_this_repo_rows_every_artifact_path_exists_on_disk(self): + for target, row in self.by_target.items(): + for rel in row["artifacts"]: + with self.subTest(target=target, path=rel): + self.assertTrue( + os.path.isfile(os.path.join(self.REPO_ROOT, rel)), + f"{target}: declared artifact {rel!r} does not exist on disk") + + def test_this_repo_rows_payload_is_scoped_to_its_own_target(self): + # Catches "payload points at the wrong plugin": every target's + # declared payload must be `plugins//` -- its OWN directory, + # never a sibling's. + for target, row in self.by_target.items(): + with self.subTest(target=target): + self.assertEqual(row["payload"], f"plugins/{target}/") + + def test_this_repo_rows_rebuild_follows_the_cd_and_run_convention(self): + # HIGH-4: a declared `rebuild` with no `cd` (e.g. the pre-fix + # `node plugins/ca-pi/tools/build.mjs`) resolves its own + # relative-path inputs against the repo root rather than the + # rebuild directory, and fails only at release time. Every declared + # `rebuild` must be `cd && `, with the entrypoint + # `cmd` names resolving relative to that directory -- structurally, + # never by actually invoking `npm run build` / `node build.mjs` + # here, which belongs to the per-plugin `tools` CI jobs, not a suite + # also run on this repo's hooks matrix. + import json as _json + import re as _re + cd_re = _re.compile(r"^cd (\S+) && (.+)$") + for target, row in self.by_target.items(): + rebuild = row["rebuild"] + if rebuild is None: + continue + with self.subTest(target=target): + match = cd_re.match(rebuild) + self.assertIsNotNone( + match, + f"{target}: rebuild {rebuild!r} does not follow " + "'cd

&& '") + rel_dir, cmd = match.groups() + abs_dir = os.path.join(self.REPO_ROOT, rel_dir) + self.assertTrue( + os.path.isdir(abs_dir), f"{target}: {rel_dir!r} does not exist") + if cmd.startswith("node "): + entry = cmd[len("node "):].strip() + self.assertTrue( + os.path.isfile(os.path.join(abs_dir, entry)), + f"{target}: rebuild entrypoint {entry!r} does not exist " + f"under {rel_dir!r}") + elif cmd == "npm run build": + package_json = os.path.join(abs_dir, "package.json") + self.assertTrue( + os.path.isfile(package_json), + f"{target}: no package.json under {rel_dir!r}") + with open(package_json, encoding="utf-8") as fh: + manifest = _json.load(fh) + self.assertIn( + "build", manifest.get("scripts", {}), + f"{target}: package.json under {rel_dir!r} declares no " + "'build' script") + else: + self.fail(f"{target}: unrecognised rebuild command shape {cmd!r}") + + # The review's own literal wording ("each pre-tag entry names a script + # under .github/scripts/") is falsified by ca-pi's real, CORRECT row -- + # `python3 tools/build-host-packages.py --check`, under the repo-root + # `tools/`, not `.github/scripts/`. The allowlist is therefore BOTH + # roots, not one. + _PRE_TAG_ALLOWED_ROOTS = (".github/scripts/", "tools/") + # check_command_catalog.py is T-34 (PENDING, not yet authored) -- the one + # declared, KNOWN-pending forward reference. Asserted as such below + # rather than silently accepted (which would let ANY future absent + # script pass unnoticed) or silently failing (which would block on a + # row that correctly anticipates a task already on the plan). + _PRE_TAG_KNOWN_PENDING = frozenset({".github/scripts/check_command_catalog.py"}) + + def test_this_repo_rows_pre_tag_scripts_resolve_under_an_allowlisted_root(self): + import re as _re + cmd_re = _re.compile(r"^python3\s+(\S+\.py)\b") + for target, row in self.by_target.items(): + for command in row["pre_tag"]: + with self.subTest(target=target, command=command): + match = cmd_re.match(command) + self.assertIsNotNone( + match, + f"{target}: pre-tag {command!r} is not a " + "'python3