diff --git a/.abcd/development/brief/01-product/01-press-release.md b/.abcd/development/brief/01-product/01-press-release.md index 62a2d3d06..07e418169 100644 --- a/.abcd/development/brief/01-product/01-press-release.md +++ b/.abcd/development/brief/01-product/01-press-release.md @@ -2,7 +2,7 @@ ## Press Release -> **abcd helps a product thinker realise an intent as a high-fidelity prototype or demonstrator — carrying the why from idea to shipped reality.** State what should exist, why, for whom, and what good looks like, as a press-release intent (`/abcd:intent ""`); abcd holds that why through planning (`/abcd:intent plan`), the readiness gate (`/abcd:intent ready`) and delivery, and when the work lands it is audited against the intent's own acceptance criteria (`/abcd:intent audit`), with the verdict recorded on the intent, so what was promised and what was delivered are read side by side. The lifeboat is a key capability of the same product: point `/abcd:disembark to ` at any repository — one that has grown unwieldy, or one that died years ago and that abcd never managed — and abcd reads it without writing a single byte back, packing its hard-won knowledge — decisions, principles, pitfalls, the graveyard of what failed, the spine of what was built and why — into a portable lifeboat artefact at ``, alongside a coverage report that names what it could *not* ground and the questions a human must answer. `/abcd:disembark probe ` renders that coverage verdict on its own, and `/abcd:embark from ` unpacks a lifeboat into a clean foundation with the same wisdom but none of the cruft. A small command surface rounds out the workflow: install (`/abcd:ahoy install`) and public release (`/abcd:launch ship`); `/abcd:capture` keeps discovered issues structured as the project evolves; and `/abcd:memory` curates a queryable knowledge substrate from those same sources. Bare invocation of any command shows status + suggested next actions; state-mutating actions require an explicit sub-verb (the universal abcd convention). +> **abcd helps a product thinker realise an intent as a high-fidelity prototype or demonstrator — carrying the why from idea to shipped reality.** State what should exist, why, for whom, and what good looks like, as a press-release intent (`/abcd:intent ""`); abcd holds that why through planning (`/abcd:intent plan`), the readiness gate (`/abcd:intent ready`) and delivery, and when the work lands it is audited against the intent's own acceptance criteria (`/abcd:intent audit`), with the verdict recorded on the intent, so what was promised and what was delivered are read side by side. The lifeboat is a key capability of the same product: point `/abcd:disembark pack ` at any repository — one that has grown unwieldy, or one that died years ago and that abcd never managed — and abcd reads it without writing a single byte back, packing its hard-won knowledge — decisions, principles, pitfalls, the graveyard of what failed, the spine of what was built and why — into a portable lifeboat artefact at ``, alongside a coverage report that names what it could *not* ground and the questions a human must answer. `/abcd:disembark probe ` renders that coverage verdict on its own, and `/abcd:embark from ` unpacks a lifeboat into a clean foundation with the same wisdom but none of the cruft. A small command surface rounds out the workflow: install (`/abcd:ahoy install`) and public release (`/abcd:launch ship`); `/abcd:capture` keeps discovered issues structured as the project evolves; and `/abcd:memory` curates a queryable knowledge substrate from those same sources. Bare invocation of any command shows status + suggested next actions; state-mutating actions require an explicit sub-verb (the universal abcd convention). > > "I know what I want built and I can tell in a minute whether a prototype got it right. What I could never do was hold that line through the engineering. abcd takes the why as I wrote it, builds against it, and shows me what was promised next to what was delivered," said Iris, a product thinker. > @@ -20,7 +20,7 @@ The lifeboat widens from whole repositories to narrower sources — a single fea ## What's In Scope -- **Pack the lifeboat:** `/abcd:disembark to ` runs three passes (settled artefacts → targeted chat retrieval → distil/compose/audit) over a project's specs, ADRs, transcripts, oracle reviews, and curated memory. `/abcd:disembark probe ` is the user-facing read-only half: the coverage report alone, rendered to stdout and written nowhere. Pack is **read-only in the source repo and writes out-of-tree** (adr-35): the source is never modified, so any repository can be mined — including a dead one abcd has never touched. Output at `` is a structured directory with synthesised principles, decisions timeline, pitfalls, a `graveyard/` of what was tried and abandoned, press-release framing, verbatim copies of specs, ADRs, and user docs, and a first-class `coverage.{json,md}` pair recording what could not be grounded (`grounded` / `partial` / `blank`), what was searched, and the question a human must answer. abcd refuses a destination it did not produce — it writes only into an absent path, an empty directory, or one carrying a parseable `_provenance.json`. Operations state (the append-only voyage log) lives at the operator level under `~/.abcd/voyage//`, keyed on the root-commit SHA and never committed. +- **Pack the lifeboat:** `/abcd:disembark pack ` runs three passes (settled artefacts → targeted chat retrieval → distil/compose/audit) over a project's specs, ADRs, transcripts, oracle reviews, and curated memory. `/abcd:disembark probe ` is the user-facing read-only half: the coverage report alone, rendered to stdout and written nowhere. Pack is **read-only in the source repo and writes out-of-tree** (adr-35): the source is never modified, so any repository can be mined — including a dead one abcd has never touched. Output at `` is a structured directory with synthesised principles, decisions timeline, pitfalls, a `graveyard/` of what was tried and abandoned, press-release framing, verbatim copies of specs, ADRs, and user docs, and a first-class `coverage.{json,md}` pair recording what could not be grounded (`grounded` / `partial` / `blank`), what was searched, and the question a human must answer. abcd refuses a destination it did not produce — it writes only into an absent path, an empty directory, or one carrying a parseable `_provenance.json`. Operations state (the append-only voyage log) lives at the operator level under `~/.abcd/voyage//`, keyed on the root-commit SHA and never committed. - **Unpack the lifeboat:** `/abcd:embark from ` reads the lifeboat, runs a press-release interview to confirm the framing with the user, scaffolds the new repo at canonical locations, and writes provenance so the rebuild knows where it came from. `` is wherever a prior disembark landed its lifeboat; there is no in-tree lifeboat home and no `home` shorthand. - **Install / promote:** `/abcd:ahoy install` bootstraps abcd in any repo (transparent prompts, visibility-driven gitignore, marker block in CLAUDE.md/AGENTS.md, prompt-router hook). `/abcd:launch ship` cuts a curated release from the single repo — `.abcd/**` excluded from the artifact by packaging — with secret/PII scans and a version stamp. - **Forward-looking discipline:** `/abcd:intent` captures product intents in three structural kinds per itd-34 — `standalone` (one user moment, one spec), `bundle-member` (coupled intents share a spec), and `discipline` (cross-cutting rules with no user moment, e.g., the itd-1 acceptance-gates rule that enforces Given-When-Then on every other spec). Standalone and bundle-member intents are press-release-shaped; disciplines use a `## Rule` template instead. `/abcd:capture` runs a structured issue ledger at `.abcd/work/issues/` rather than free-form notes. `/abcd:intent ready` answers the question that gates the build: is this intent ready to implement, and if not, what is missing. After shipping, `/abcd:intent audit` (Role 1 of `intent-auditor`) reviews delivered reality against the press release, and `/abcd:intent consistency` (Role 2, per itd-48, which superseded itd-31) catches drift between documents, filing each contradiction it finds as an issue. Two companions are designed and not yet built: `/abcd:intent grill` (per itd-27), a Socratic interview that stress-tests an intent, or a brief section, before it is planned; and `/abcd:intent shape` (Role 3), which keeps each intent's `kind` honest as the corpus grows. @@ -34,9 +34,9 @@ Code-bundling lifeboats, cross-corpus synthesis, public-source vendoring with pr ## Acceptance Criteria -- **Given** a corpus repo with a spec store, ADRs, and a memory backend present, **when** `/abcd:disembark to ` runs to completion, **then** `` contains all sections specified in [`04-surfaces/02-disembark.md § 5`](../04-surfaces/02-disembark.md#5-output-shape) plus `coverage.{json,md}`, the source repo's tree hash is byte-for-byte unchanged, and the lifeboat review returns a registered verdict (`SHIP` / `NEEDS_WORK` / `MAJOR_RETHINK`) with specific findings (not vague approval). -- **Given** a destination directory that is non-empty and carries no parseable `_provenance.json`, **when** `/abcd:disembark to ` runs, **then** the command refuses with the offending path cited and writes nothing — abcd never overwrites a directory it did not produce (adr-35). -- **Given** a lifeboat produced by `/abcd:disembark to ` and an empty target repo, **when** `/abcd:embark from ` runs, **then** the user is presented with the press-release interview as the first interaction, the amended press release becomes the new repo's `.abcd/development/brief/README.md`, and all canonical-location files (specs, ADRs, memory, docs, terminology, principles) land at the locations specified in [`04-surfaces/03-embark.md § 3`](../04-surfaces/03-embark.md#3-scaffold-steps). +- **Given** a corpus repo with a spec store, ADRs, and a memory backend present, **when** `/abcd:disembark pack ` runs to completion, **then** `` contains all sections specified in [`04-surfaces/02-disembark.md § 5`](../04-surfaces/02-disembark.md#5-output-shape) plus `coverage.{json,md}`, the source repo's tree hash is byte-for-byte unchanged, and the lifeboat review returns a registered verdict (`SHIP` / `NEEDS_WORK` / `MAJOR_RETHINK`) with specific findings (not vague approval). +- **Given** a destination directory that is non-empty and carries no parseable `_provenance.json`, **when** `/abcd:disembark pack ` runs, **then** the command refuses with the offending path cited and writes nothing — abcd never overwrites a directory it did not produce (adr-35). +- **Given** a lifeboat produced by `/abcd:disembark pack ` and an empty target repo, **when** `/abcd:embark from ` runs, **then** the user is presented with the press-release interview as the first interaction, the amended press release becomes the new repo's `.abcd/development/brief/README.md`, and all canonical-location files (specs, ADRs, memory, docs, terminology, principles) land at the locations specified in [`04-surfaces/03-embark.md § 3`](../04-surfaces/03-embark.md#3-scaffold-steps). - **Given** a fresh repo with no `.abcd/` directory, **when** `/abcd:ahoy install` runs to completion, **then** the repo is bootstrapped per [`04-surfaces/01-ahoy.md § Acceptance`](../04-surfaces/01-ahoy.md#acceptance), and re-running `install` at the same `setup_version` is idempotent (no marker duplication, transparent re-confirm of visibility). - **Given** a clean tree with a deliberate PII fixture (e.g., a real email in a comment), **when** `/abcd:launch ship` runs, **then** preflight hard-fails with the offending file/line cited and no payload is written; a `/abcd:launch dry-run` on the same tree prints the would-refuse finding and exits 0 (report-only preview per [`04-surfaces/04-launch.md`](../04-surfaces/04-launch.md)). - **Given** the user runs `/abcd:intent ""` (canonical bare quoted create), **when** the interview completes, **then** an `intents/drafts/itd-N-.md` file exists with press-release content, customer quote from the persona registry, and a `## Acceptance Criteria` section in Given-When-Then format (per itd-1). diff --git a/.abcd/development/brief/01-product/02-context.md b/.abcd/development/brief/01-product/02-context.md index 4ff4c2156..572a08159 100644 --- a/.abcd/development/brief/01-product/02-context.md +++ b/.abcd/development/brief/01-product/02-context.md @@ -5,7 +5,7 @@ You did a manual lifeboat from iDelphiZero → iDelphi (`idelphiDev/.work/lifebo abcd ships these user-facing commands: - **`/abcd:ahoy install`** — install / update abcd in any project (bootstraps configuration, gitignore, marker blocks, PATH symlink). Bare `/abcd:ahoy` shows status+help. -- **`/abcd:disembark to `** — *pack* a lifeboat by **reading** `` — any repository, including one abcd has never touched — and writing the artefact **out-of-tree** to the operator-chosen ``. The source is never written to; the destination must be absent, an empty directory, or one carrying a parseable `_provenance.json` ([adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md)). Bare `/abcd:disembark` shows status+help; `probe ` and `dry-run` sub-verbs preview without writing — `probe` reports **coverage** (which brief sections a repository can ground, and which come back blank). +- **`/abcd:disembark pack `** — *pack* a lifeboat by **reading** `` — any repository, including one abcd has never touched — and writing the artefact **out-of-tree** to the operator-chosen ``. The source is never written to; the destination must be absent, an empty directory, or one carrying a parseable `_provenance.json` ([adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md)). Bare `/abcd:disembark` shows status+help; `probe ` and `dry-run` sub-verbs preview without writing — `probe` reports **coverage** (which brief sections a repository can ground, and which come back blank). - **`/abcd:embark from `** — *unpack* the lifeboat at `` (wherever a disembark wrote it) into a (typically empty) target project. Bare `/abcd:embark` shows status+help; `scan` and `probe ` sub-verbs discover/inspect without unpacking. - **`/abcd:launch ship`** — cut a curated release from the single repo (the packaging filter denies `.abcd/**`, so the released binaries do not carry it), scrub for PII/secrets, stamp the version, update the marketplace entry. Bare `/abcd:launch` shows status+help; `dry-run` sub-verb runs the full pre-flight gate suite without writing the release artifact. - **`/abcd:intent`** — bare quoted `/abcd:intent ""` is the canonical create (spc-30/itd-46), plus the `plan` / `ready` / `audit` / `link` / `consistency` sub-verbs that ship (alongside the deprecated `new` alias; `consistency` per itd-48, which superseded itd-31), with `refine` / `grill` / `ship` / `shape` / `reclassify` remaining design targets. There is no plain `list` sub-verb — it is folded into the bare render per SD001. Manages **intents** (press-release-format intent docs at `.abcd/development/intents/{drafts,planned,shipped,disciplines,superseded}/`). `plan` promotes an intent to `planned/` and plans the work as a spec on the native spec store ([adr-26](../../decisions/adrs/0026-native-spec-layer-ccpm-backend.md); the companion harness `ccpm` as the deeper backend); `ship` drives that spec to completion (or the full pipeline if from drafts/); a spec-close hook reconciles standalone/bundle intents planned → shipped automatically on a successful close (spc-28 `intent_lifecycle.reconcile`); disciplines move from drafts/ to disciplines/ on plan and stay there. Bare `/abcd:intent` shows status+help. @@ -21,7 +21,7 @@ abcd ships these user-facing commands: ``` [source repo: specs, .abcd/memory/ (or legacy memory/), ADRs, docs, code, transcripts] │ READ-ONLY — the source tree is never written to - │ /abcd:disembark to (PACK) + │ /abcd:disembark pack (PACK) ▼ [lifeboat artefact at : a portable directory, out-of-tree] ├── README.md, press-release.md, principles.md ← synthesised diff --git a/.abcd/development/brief/02-constraints/01-platform.md b/.abcd/development/brief/02-constraints/01-platform.md index b220ca9d4..dc8e38c75 100644 --- a/.abcd/development/brief/02-constraints/01-platform.md +++ b/.abcd/development/brief/02-constraints/01-platform.md @@ -14,7 +14,7 @@ The core is a transport-agnostic Go package ([adr-23](../../decisions/adrs/0023- ## Lifeboat path -**Out-of-tree, at an operator-chosen destination** — `disembark to `. There is no in-tree lifeboat home and nothing to gitignore in the source, because **disembark never writes to the source repo** (a test hashes its tree before and after). Per [adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md), superseding adr-4's `.abcd/lifeboat/`: mining a dead or archived project must not require installing abcd into a repo we only want to read. +**Out-of-tree, at an operator-chosen destination** — `disembark pack `. There is no in-tree lifeboat home and nothing to gitignore in the source, because **disembark never writes to the source repo** (a test hashes its tree before and after). Per [adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md), superseding adr-4's `.abcd/lifeboat/`: mining a dead or archived project must not require installing abcd into a repo we only want to read. **Lifeboat is always *output*.** `` holds the latest snapshot only — produced by `disembark`, regenerable from current state; there is no `lifeboat-v1/` / `lifeboat-v2/` proliferation. Re-running is governed by a **destination safety gate**, not adr-4's `.bak` overwrite: refuse unless the destination is absent, an empty directory, or one carrying a parseable `_provenance.json`. **abcd never overwrites a directory it did not produce.** Past disembarks are recorded as manifests (hash + file list + label) at the operator level, not as preserved snapshots — see [`04-surfaces/03-embark.md § 7`](../04-surfaces/03-embark.md#7-voyage-layout--embarkdisembark-provenance-and-history). @@ -24,7 +24,7 @@ The core is a transport-agnostic Go package ([adr-23](../../decisions/adrs/0023- **Embark sources, in order (post bare-as-help refactor — see [`04-surfaces/03-embark.md`](../04-surfaces/03-embark.md)):** -1. `embark from ` (any explicit path to a lifeboat destination a disembark wrote) — **there is no `home` shorthand**, because there is no in-tree lifeboat home to expand it to (adr-35). The round-trip / self-test case is just `disembark to ` followed by `embark from `. +1. `embark from ` (any explicit path to a lifeboat destination a disembark wrote) — **there is no `home` shorthand**, because there is no in-tree lifeboat home to expand it to (adr-35). The round-trip / self-test case is just `disembark pack ` followed by `embark from `. 2. `embark scan` (or `embark scan --deep`) → discovery sub-verb that walks sibling directories (`../`), lists **lifeboat destinations** — directories carrying a parseable `_provenance.json`, the same marker the destination safety gate keys on — ranked by mtime; does not unpack; pass the chosen path to `embark from ` 3. Free-text path input via the embark interview if `` is omitted on `from` diff --git a/.abcd/development/brief/02-constraints/03-invariants.md b/.abcd/development/brief/02-constraints/03-invariants.md index 639fc5a10..43e201c9d 100644 --- a/.abcd/development/brief/02-constraints/03-invariants.md +++ b/.abcd/development/brief/02-constraints/03-invariants.md @@ -16,7 +16,7 @@ The following are non-negotiable invariants — any architectural choice that vi 5. **Visibility is one switch** — `repo.visibility` (private | public) is the single switch governing what gets committed. No per-subdirectory exceptions. If sensitivity is a concern, set visibility=public (which gitignores the entire `.abcd/` namespace). See [`05-internals/03-configuration.md § 1`](../05-internals/03-configuration.md#1-visibility-driven-gitignore-policy). -6. **Lifeboat is always *output*, and disembark never writes to the source** — the lifeboat is regenerable, and it is written **out-of-tree** to an operator-chosen ``, never back into the repo being read (`disembark to `). Operations history lives at the operator level, `~/.abcd/voyage//`, keyed on the root-commit SHA like the history store — never committed, and never accumulated as stale snapshots. Per [adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md), superseding adr-4's in-tree `.abcd/lifeboat/` and `.abcd/development/voyage/`. See [`02-constraints/01-platform.md § Lifeboat path`](01-platform.md#lifeboat-path) and [`04-surfaces/03-embark.md § 7`](../04-surfaces/03-embark.md#7-voyage-layout--embarkdisembark-provenance-and-history). +6. **Lifeboat is always *output*, and disembark never writes to the source** — the lifeboat is regenerable, and it is written **out-of-tree** to an operator-chosen ``, never back into the repo being read (`disembark pack `). Operations history lives at the operator level, `~/.abcd/voyage//`, keyed on the root-commit SHA like the history store — never committed, and never accumulated as stale snapshots. Per [adr-35](../../decisions/adrs/0035-lifeboat-as-coverage-experiment.md), superseding adr-4's in-tree `.abcd/lifeboat/` and `.abcd/development/voyage/`. See [`02-constraints/01-platform.md § Lifeboat path`](01-platform.md#lifeboat-path) and [`04-surfaces/03-embark.md § 7`](../04-surfaces/03-embark.md#7-voyage-layout--embarkdisembark-provenance-and-history). 7. **Implicit operations never touch the network** — abcd asks the network nothing on its own: implicit checks read only disk state; a fetch happens diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index f14392299..532e07d65 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -417,8 +417,10 @@ host's BLOCKING status on that event and would stop the sub-agent finishing. **The `PATH` rung is owned-only** (GHSA-gx3m-3224-qqcv, CWE-426). It accepts only an absolute resolution out of a directory that is neither under the shim's -working directory nor world-writable — the shapes the documented install never -produces (iss-2609012039117381) — and only when the home-scoped `path-entry` +working directory nor world-writable, naming a binary that is not itself +world-writable once a symlink is followed to the file it names — the shapes the +documented install never produces (iss-2609012039117381, iss-2609020352438590) +— and only when the home-scoped `path-entry` record names that exact path as this machine's installed binary. The record is a string comparison and no hashing, because adr-46 keeps the fast path at one file test. Both install routes write it, and the ahoy installer writes it for **every** diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 52f5cbcaa..a6c57457a 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -95,7 +95,10 @@ since the last tag: headline intents told as prose, the rest listed by title, persona quotes carried word for word. The binary holds it to the changelog's rule (every intent in that set cited once, nothing else, nothing planned), checks each quote against its source, and runs the outbound policy and the -persona registry over it. The outgoing page moves to +persona registry over it. Both rendered documents are also held to the bar the +launch scan holds the same files to: a hard_fail finding of the canonical +scanner (a token, a key, the caller's own home or identity) refuses the cut, +named by kind and line, never quoted (iss-2609290405381338). The outgoing page moves to `.abcd/development/releases/.md`, then the page is written, then the changelog heading; a failure rolls the earlier writes back. A fixes-only cut writes no page. A refused payload returns every reason as data, and the host @@ -372,7 +375,9 @@ same way, with the opposite meaning: the dated release is the one just cut and the newest tag is the right baseline. The preview refuses there too, calling the release not tagged yet rather than the previous one and naming the explicit baseline that measures against the release before it; the cut is not blocked, -because it diffs before it writes its heading. A baseline that cannot be read is +because it diffs before it writes its heading. A second cut in that window is a +release in flight, and it refuses as one before any pre-flight runs, so it never +names that explicit baseline, which the cut does not take. A baseline that cannot be read is a named refusal, never an empty diff. Every preview, and every cut that renders a payload, writes its pre-flight diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index f298223d9..0f97f8390 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -301,7 +301,7 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | Subcommand | Purpose | File movement | |---|---|---| -| `/abcd:intent` (no args) | Read-only status: bucket counts (drafts / planned / shipped / disciplines / superseded) with the count of owed fidelity reviews beside them (the owed listing's total, from the same reader), open/closed spec counts, the itd↔spc links, and in the machine-readable form a per-intent listing (id, title, bucket, `ac_state` `real` or `seeded`, and the filing date a timestamp id encodes, null for an ordinal id; iss-242), a ledger-routing hint (`abcd capture "…"` for an observation, `abcd intent "…"` for a user-facing change), and an ideate-routing line (a big, unproven idea? `abcd ideate` runs the optional admission gauntlet and records the verdict either way) | — | +| `/abcd:intent` (no args) | Read-only status: bucket counts (drafts / planned / shipped / disciplines / superseded) with the count of owed intent audits beside them (the owed listing's total, from the same reader), open/closed spec counts, the itd↔spc links, and in the machine-readable form a per-intent listing (id, title, bucket, `ac_state` `real` or `seeded`, and the filing date a timestamp id encodes, null for an ordinal id; iss-242), a ledger-routing hint (`abcd capture "…"` for an observation, `abcd intent "…"` for a user-facing change), and an ideate-routing line (a big, unproven idea? `abcd ideate` runs the optional admission gauntlet and records the verdict either way) | — | | `/abcd:intent ""` | **Canonical create** (spc-30 (predecessor store)/itd-46): a leading quoted seed is the canonical create entry. Seeds a draft skeleton whose `## Press Release` is the quoted text as prose, under an H1 derived from the text's first sentence (cut on a word boundary at the slug cap) or given as a title — one line, non-empty, redacted like the text — with Why This Matters and Acceptance Criteria seeded as prompts for the human to fill; assigns `itd-N` and derives the slug from the text; writes `suggested_kind: null`. An optional impact (additive, breaking or fix) stamps the draft's product impact at create time, and an optional production mode (hand-written, dictated-and-formatted or scribe-transcribed) stamps how its text was produced (itd-178); the draft's `origin` carries no flag and is derived from the verb that ran. Before the draft is written, under the store's mint lock, its title and press release are matched against every open and resolved issue and every intent by the term-overlap heuristic `/abcd:capture` uses (itd-2609212137116617): a record at or above `match.threshold` is written onto the draft as `duplicates:` or `refines:`, at most three links, and the output lists the near misses below it with their scores; the match never refuses the create. A leading quote always creates — never falls through to bare render | writes to `drafts/itd-N-.md` (no spec created) | | The grill step, on one intent id | Socratic adversarial interview that stress-tests an intent for vagueness, missing acceptance, hidden assumptions before planning. Glossary-aware once `terminology/` exists. A brief-section mode would stress-test a brief section instead. (per itd-27, `intents/planned/` — a later phase; no grill sub-verb ships yet) | (stays in current state) | | Plan (one intent id) | Plans a draft: mints its native spec, injects the bidirectional link (intent `spec_id` ↔ spec `intent`), stamps an identity onto every unmarked scope condition, and moves the file `drafts/` → `planned/`. An impact given at planning stamps the INTENT's product-impact judgement, because the planning interview is where that judgement is made: validated at the create path's bar (never `internal`), written as the bare scalar the create path writes, refused before anything moves when it disagrees with a judgement the record already carries, and a no-op when it agrees; without one the field is left as found and the judgement stays owed to the close (iss-2609170726457256). A production mode given at planning stamps the MINTED SPEC's disclosure pair; the intent's own stamp was written at create time and is never rewritten. On an intent already in `planned/` it does the identity step alone (no spec, no move), takes an impact under the same rules, and refuses when nothing is unmarked and no judgement is added — except where the planned record's `spec_id` is null, when it mints (or reuses the spec already naming the intent) and links the spec in place on the draft's Acceptance Criteria bar, still with no move, and the readiness gate's remedy for the missing spec names this call (iss-2609211738504433). | `drafts/` → `planned/` (stamp step: no move) | @@ -310,7 +310,7 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | Audit (one intent id) | **Role 1 — single-document fidelity.** Takes a **shipped** intent and nothing else: a record still in `drafts/`, `planned/`, `disciplines/` or `superseded/` is refused by name, because only a shipped intent has a delivered reality to be judged against. Compares the intent's press release + acceptance criteria against delivered reality (code, configs, docs, tests). Per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) appended to the intent's `## Audit Notes`. The request it writes for the host states the criteria count, lists every scope condition under the identity the verdict disposes it by, and carries the verdict shape rendered from the structure the ingest decodes, so the request alone is enough to write a verdict against. Its result names the receipt's state and, separately, whether it wrote the request: a re-emit of an owed receipt rewrites it, and a re-emit of an ingested or dead-lettered one writes none and names no request path. Aligns with the spec store's `plan-review` / `impl-review` / `completion-review` vocabulary — same operation shape (adversarial second opinion), different opponent (press release vs engineering spec). spc-12 (predecessor store) ships this **manual** verb; spc-28 (predecessor store) ships the on-close hook (move `planned → shipped` + queue a review), which only queues: the queue is paid by the drain row below, on demand, and nothing runs the reviewer on its own (spc-6 (predecessor store) disowned auto-firing). | (stays) | | Owed reviews (the audit sub-verb with no argument) | **The fidelity-review debt, listed** (itd-2609150819445595). Every close that ships an intent parks an OWED marker, so a review is owed by construction; this reads the first review marker of every intent in `shipped/` — the marker the re-emit also reuses — and lists the debt. The owed set is OWED plus no marker at all (shipped before markers, or a ship whose receipt failed to mint), each named with its receipt, or with none and the note that the re-emit mints one, and the re-emit command. A dead-lettered review is listed under its own heading as unreviewed, with the reason its quarantine block recorded, and is not counted; an ingested review is not listed. The machine-readable form carries one entry per shipped intent — id, state, receipt, and the re-emit where the review is owed — and never a path into the local tier: it names the re-emit, not the request file, which is gitignored and may have been swept. Writes nothing, exits 0, and no gate reads it: the close mints the debt in the same change, so a refusal on it would block by construction. The same reader supplies the owed count on the bare status board and the record dispatcher's next move for a shipped intent. | (no move; read-only) | | Drain (the audit sub-verb's owed form, optionally capped) | **The bounded command that pays the review debt** (itd-53). The owed set is the owed listing's, from the same reader; the ordering and the cap are the intent store's: oldest shipped first — the day the intent entered `shipped/`, read from the site's one history walk, with an intent not yet committed last and ties in the order the ids were minted; a history that cannot be read leaves every day unknown, reported as unknown rather than as not yet committed, and the queue in mint order — at most the cap's count of entries (zero or absent: no cap; negative: refused, naming the value), and the summary names how many remain beyond the cap. It emits the oldest entry's request through the single audit's own emit, minting the receipt if there was none, with the single audit's routing: the route is resolved before anything is written, a route override for the auditor applies as it does to one audit, and the request carries its routing section and the result its routing member. An entry whose request cannot be emitted (a malformed spec id, an unreadable file, a local tier that cannot be written) is listed with its error, the home in any path it names shown as `~` (as the single audit's refusal shows it), and the next entry is emitted instead, so one bad record never blocks the drain; the request is written before the intent file, so a failed emit parks no OWED stub and the entry keeps the receipt state it had. It prints the ordered list and that request's path, so a host without the plugin page drives the drain by hand: audit, ingest, run again. It runs no reviewer: the plugin page runs the loop one audit at a time through the request/ingest pair; with no auditor available every entry stays owed and the summary says why nothing ran; a verdict lands exactly as a single audit's does, and a NOT_MET on an intent the drain reaches — every one of them already shipped — is captured through `capture` naming the receipt, never fixed. A cap without the owed form, and the owed form with an intent id or with the drift check, are refused. Nothing starts the drain on its own: no hook, gate or schedule, and the close hook still only enqueues. | (no move; writes the head's OWED stub and request, as the one-intent audit does) | -| Issue drift (the whole corpus, optionally strict) | **The promote join's drift check** (itd-4 AC3, in the predecessor store's spc-23 shape): walks the intent store and the issue ledger, readings included, and reports every join that does not read the same from both ends — an intent naming a record in `related_issues` that does not name it back in `related_intents` (from an issue's end a one-way `related_intents` is a loose relation and stays silent; a reading item carries none, so from its end it is reported), either end naming a record the tree does not hold, a shipped intent naming an issue that is not in `resolved/`, and a record still carrying a retired back-link key. Each finding is a warning on stderr and the run exits 0; the strict form exits 1 on any finding, for a CI gate. Findings land in `.abcd/.work.local/logs/audit/issue-drift-/report.json`. | (no move; writes only its receipt) | +| Issue drift (the whole corpus, optionally strict) | **The promote join's drift check** (itd-4 AC3, in the predecessor store's spc-23 shape): walks the intent store and the issue ledger, readings included, and reports every join that does not read the same from both ends — an intent naming a record in `related_issues` that does not name it back in `related_intents` (from an issue's end a one-way `related_intents` is a loose relation and stays silent; a reading item carries none, so from its end it is reported), either end naming a record the tree does not hold, a shipped intent naming an issue that is not in `resolved/`, and a record still carrying a retired back-link key. Each finding is a warning on stderr and the run exits 0; the strict form exits 1 on any finding, for a CI gate. It names the checkout and branch whose ledger it read, as every capture verb does. Findings land in `.abcd/.work.local/logs/audit/issue-drift-/report.json`. | (no move; writes only its receipt) | | Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). The machine-readable result says what the ingest recorded — the verdict, a quarantine, or nothing — and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states the conditions it recorded untested under a name of its own, so its result never reads as a rollup. A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. A verdict whose rendered prose cites a record id that names no record is refused, naming the id, with nothing written, wherever the repository's record-lint gates prose citations in the intent store. Each block closes on its own closing line, so prose written below it survives a replacement, and only a marker on a live line of `## Audit Notes` is review state: one in a fenced block or an HTML comment is an example. | (no move; updates `## Audit Notes`) | | Condition disposition (one shipped intent id, optionally one condition id) | **The second writer into the scope-condition disposition surface.** With the intent alone it is read-only: every scope condition the intent carries, with its standing disposition and the block that disposition came from, or `untested (no block)`; the machine-readable form carries the whole history and the fold. With a condition identity it writes one disposition against a **shipped** intent — `survived`, `narrowed`, `falsified` or `untested` — joined to what occasioned it: a reading item at any position, or a delivered intent in `shipped/` whose delivery changed the condition's standing. It appends one dated block to `## Audit Notes`, beside the fidelity verdict's blocks and in the same bullet shape. A condition's standing is its latest reading-occasioned block where it has one, and otherwise its latest verdict block: a verdict overrides a reading-occasioned block only where its rationale names that block's occasion, wherever the two sit in the section; the verdict ingest reports what it leaves standing, and a re-ingest for the same receipt that names the occasion replaces the ingested verdict. Refused, with nothing written: an intent not in `shipped/` (naming its bucket), an identity the intent does not carry or carries twice, a value outside the four, grounds below the substance floor, `narrowed` without a narrowing or a narrowing on any other value, an occasion that does not resolve, and the intent itself as its own occasion. Grounds and narrowing are redacted before the write. When a reading item's `constraint_in_play` cites a different condition's identity, the mismatch is reported and never refused: the reading names the tension and the researcher marks the condition. The block sits under the heading every reading's assembler withholds, so no disposition reaches a reading. | (no move; appends to `## Audit Notes`) | | Consistency (the whole corpus, or one intent id) | **Role 2 — cross-document fidelity** (itd-48). Assembles the corpus — every brief page, and every intent outside `superseded/` reduced to its title, press release, scope, decisions and rule — into one input under the local tier, and writes the request beside it: the five judgement classes (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts), the rubric, the findings shape rendered from the structure the ingest decodes, the host-computed provenance pair the audit's request carries, and the commit the tree stood at. With an intent id the pass is that intent against the rest of the corpus, and every finding must have an end in it; a superseded or unknown intent is refused. The judgement rides the host: the intent-auditor's Role 2 reads the corpus and returns findings, each naming exactly two ends, quoted. The receipt is deterministic over the scope and the corpus, so a re-emit over an unchanged corpus reuses it. | (no move; writes the request and the corpus to the local tier) | diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 3e2cbad1f..2b22e71c6 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -538,7 +538,9 @@ for ad-hoc scribbles. the edge again, and an absent target, a self-edge or an unblock of an edge the record does not hold is refused with nothing written. - **Given** a ledger of open issues, **when** the user lists them, **then** the - output carries id, state, severity and slug in derived-priority order: + output carries id, state, severity, slug and a one-line summary (the first + line of the body, less a leading markdown heading or blockquote marker, + clipped) in derived-priority order: unblocked first, then severity, with rows blocked by an open dependency demoted and annotated with their blockers. - **Given** an abcd-installed repo, **when** the user runs bare diff --git a/.abcd/development/brief/04-surfaces/10-docs.md b/.abcd/development/brief/04-surfaces/10-docs.md index dadc33e00..5ad29229a 100644 --- a/.abcd/development/brief/04-surfaces/10-docs.md +++ b/.abcd/development/brief/04-surfaces/10-docs.md @@ -49,8 +49,10 @@ in a gate, which is what keeps the lint itself deterministic and offline. - **The citation refresh** fetches every cited URL once and rewrites the committed citation baseline. Each URL gets exactly one bounded attempt with no retries, and no response body is read: liveness is judged from the status - line. Sources that refuse automated fetchers are printed as a manual checklist - rather than recorded as broken. + line. A redirect chain that has reached https is never followed back to + plaintext http. Sources that refuse automated fetchers, and sources whose + redirects leave https, are printed as a manual checklist rather than recorded + as broken. - **The citation confirmation** records that a human verified a citation the fetcher could not read, either from named URLs or from a receipt file. Today the technical facilitator clears the printed checklist and names the URLs on the command line; diff --git a/.abcd/development/brief/04-surfaces/13-consult.md b/.abcd/development/brief/04-surfaces/13-consult.md index 8833a4737..3ea9e21c9 100644 --- a/.abcd/development/brief/04-surfaces/13-consult.md +++ b/.abcd/development/brief/04-surfaces/13-consult.md @@ -155,6 +155,6 @@ corpus and its ledger. -There is no shipped surface: the command tree registers no `abcd consult` verb, so there are no flags and no sub-verbs to list. +It ships as a host-delegated command page: the command tree registers no `abcd consult` verb, so there are no flags and no sub-verbs to list. diff --git a/.abcd/development/brief/04-surfaces/14-ingest.md b/.abcd/development/brief/04-surfaces/14-ingest.md index ca9389a6f..90179e83c 100644 --- a/.abcd/development/brief/04-surfaces/14-ingest.md +++ b/.abcd/development/brief/04-surfaces/14-ingest.md @@ -115,6 +115,6 @@ contract. -There is no shipped surface: the command tree registers no `abcd ingest` verb, so there are no flags and no sub-verbs to list. +It ships as a host-delegated command page: the command tree registers no `abcd ingest` verb, so there are no flags and no sub-verbs to list. diff --git a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md index f1b579b2d..8ca346d39 100644 --- a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md +++ b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md @@ -173,6 +173,6 @@ layout the shipped abcd surfaces then operate over. -There is no shipped surface: the command tree registers no `abcd prepare-this-repo` verb, so there are no flags and no sub-verbs to list. +It ships as a host-delegated command page: the command tree registers no `abcd prepare-this-repo` verb, so there are no flags and no sub-verbs to list. diff --git a/.abcd/development/brief/04-surfaces/16-lint.md b/.abcd/development/brief/04-surfaces/16-lint.md index e9015a0c6..64b6af842 100644 --- a/.abcd/development/brief/04-surfaces/16-lint.md +++ b/.abcd/development/brief/04-surfaces/16-lint.md @@ -138,7 +138,7 @@ iss-2608231000561060. | id | severity | checks | |---|---|---| -| `three-tier-layout` | error | `.abcd/development/` and `.abcd/work/` present as directories on disk; `.abcd/.work.local/`, when present, gitignored, which is the rule's one committedness assertion; no local-tier artefacts (`NEXT.md`, `scratch/`, `logs/`) sitting directly in one of the two shared tiers | +| `three-tier-layout` | error | `.abcd/development/` and `.abcd/work/` present as directories on disk; `.abcd/.work.local/`, when present, gitignored, which is the rule's one committedness assertion; no local-tier artefacts (`NEXT.md`, `scratch/`, `logs/`, in any case) directly in one of the two shared tiers or at the `.abcd/` root, and no `NEXT.md` at any depth in a shared tier | | `conventions-router` | error | `AGENTS.md` present at the repo root | | `decision-durability` | warn | a committed `.abcd/work/DECISIONS.md`; decisions not living only in the gitignored layer | | `docs-currency` | warn | reuses the docs-lint engine where `docs/` exists, and says so where it cannot: a repo with a `docs/` tree but no docs-lint configuration, and a configuration that will not load, each raise a finding against `.abcd/docs-lint.json` rather than passing quietly | diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index 86e0c3c2d..91a17425a 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -280,7 +280,9 @@ document and every command its output is piped on to; and into a substitution in with that pipe as its input — and a `pkill` or `killall` selecting by user, group or terminal, its value written apart or attached, as selecting every session under the account; `pkill`'s signal name -is read as a signal first, in any case. In a repository with more +is read as a signal first, in any case. Flipping a source-ledger line to +public citation is blocked, because the ledger records that a person chose to +cite the source, and that choice is the person's to make. In a repository with more than one worktree, a stash or pop that does not name its entry is warned about, because the stash stack is shared across worktrees. Where the reading is a guess, over-blocking is the direction the guard takes. diff --git a/.abcd/development/brief/04-surfaces/18-ideate.md b/.abcd/development/brief/04-surfaces/18-ideate.md index b150041a4..675aff0b8 100644 --- a/.abcd/development/brief/04-surfaces/18-ideate.md +++ b/.abcd/development/brief/04-surfaces/18-ideate.md @@ -110,11 +110,16 @@ something a later reader depends on: - **The payload cannot be tied to a definition**: no `schema_version`, one this build does not support, or no semver `prompt_version`. A verdict is the output of a named prompt at a named version, and an unstamped payload cannot be traced - back to what produced it. + back to what produced it. A key the contract does not declare refuses the + document too; the refusal names it redacted through the canonical scanner, and + describes it when the scanner cannot be trusted. - **The evidence does not hold up**: a cited record id that does not resolve, a cited value that is not a record id at all, a claim naming no primary source, an out-of-enum verdict or outcome. Every closed set is closed, and an unregistered - value refuses the document rather than being coerced. + value refuses the document rather than being coerced. The refusal names the + field and describes the value by its length, never quoting it: closed-set + members are not redacted on the way in, so a quote would carry whatever was + pasted there into the terminal and the transcript. - **The record would mislead a later reader**: an empty `rejected_alternatives` list with no explicit marker saying nothing was weighed, because silence and "nothing was weighed" read identically; or a verdict record that already exists diff --git a/.abcd/development/brief/04-surfaces/32-scribe.md b/.abcd/development/brief/04-surfaces/32-scribe.md index 511a5ad93..b41b6c3a4 100644 --- a/.abcd/development/brief/04-surfaces/32-scribe.md +++ b/.abcd/development/brief/04-surfaces/32-scribe.md @@ -99,6 +99,15 @@ until all of the following hold: condition or a surprise that does not stand verbatim in the supplied text, once whitespace is folded, is one the researcher did not write. The scribe reformats; the check is that every word it carries was already there. +- **A refusal carries no payload value it cannot vouch for.** A refusal returns + to the terminal and the transcript, so it quotes a payload value only when the + value has a closed shape: a record handle, a state of the vocabulary, a + sha-256 digest. Any other value, free text included, is described by its + length, and the field and entry position say where the fault is. A key the + refusal names, undeclared or repeated, is the one value the reader needs, so + it is named redacted through the canonical scanner, and described when the + scanner cannot be trusted. The parked context and manifest are held to the + same rule, because a session could have rewritten them. - **Every answer is the run's, once.** An answered or outstanding item must be one of the run's items, and one item takes one answer. The run's items are listed through the ledger's directories down to the run's own, and a symlink at any diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 419b4cc40..090c8ee63 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -97,9 +97,10 @@ what map a chapter to its commands, so a chapter with no row here, or a row naming a chapter that does not exist, is refused by name, as is a chapter without its markers. The generator still writes every other chapter and then exits 1 naming each refusal, and the drift test fails the same way. A chapter whose -command the tree does not register — a staged design target, or a host-delegated -command with no verb — carries one sentence saying there is no shipped surface, -so no chapter lacks the block. +command the tree does not register carries one sentence instead, so no chapter +lacks the block: a staged design target's says there is no shipped surface, and +a host-delegated command's, whose row here reads shipped, says it ships as a +command page with no verb, so the block never contradicts its own row. Two tests in `internal/surface/cli` hold it, and both run in `go test ./...`, so in `make preflight` and in CI. `TestSurfaceAppendicesMatchCommandTree` @@ -126,6 +127,9 @@ say what the surface is for, and let the appendix say how it is spelled. line above them saying that `abcd --help --agent` expands the list. With `--agent` the help renders two blocks: the person's groups, then the verbs agents and hosts call, each line naming the command page an agent reads next (itd-146). +Each block sizes its own name column, so the person's groups read the same in +both forms, and asking for the expanded list any other way, on the bare call or +through the help verb, is refused naming the one spelling that works. Every other command's help is the framework's own, except that it opens with the command's sentence (the section below). diff --git a/.abcd/development/brief/05-internals/02-adapters.md b/.abcd/development/brief/05-internals/02-adapters.md index 85211ce27..69b7b6e4e 100644 --- a/.abcd/development/brief/05-internals/02-adapters.md +++ b/.abcd/development/brief/05-internals/02-adapters.md @@ -29,8 +29,9 @@ Each dropped hard dependency maps to exactly one seam under `internal/adapter/`: its default is the seam's native path (host-delegated for `oracle`, the native store/loop/scan for the rest). A missing or misbehaving external backend degrades to the native default rather than breaking abcd — each seam carries its own thin -capability contract. Adding a backend = implement the interface and register it in -`internal/registry`; consumers are untouched. See +capability contract. Adding a backend = implement the interface and register it in a +registry (the design target `internal/registry`, which does not exist yet); +consumers are untouched. Of the five, only `scanner` is a directory under `internal/adapter/` in the tree; `oracle`, `history`, `spec` and `run` are design targets, each introduced by the first intent that consumes it, and today their native paths live in `internal/core` (`oracle`, `history`, `spec`, `implement`). `internal/adapter/` also holds adapters that are not capability seams: `gitleaks`, `hosting` and `openaiapi`. [`internal/README.md`](../../../../internal/README.md) § Planned seams is the gated list. See [`03-configuration.md`](03-configuration.md) for the config schema. **Oracle consumers.** `lifeboat-reviewer`, `press-release-composer`, and @@ -153,8 +154,8 @@ structure is ambiguous. | Source reader | Source / Role | Notes | |---|---|---| -| spec reader | native spec store (`internal/adapter/spec`) | Reads the native spec/task tree, newest-first; powers spec-essence | -| transcript reader | native transcript store (`internal/adapter/history`) | Reads the root-SHA-keyed local corpus; merge by timestamp/content hash when an imported specstory source is also present | +| spec reader | native spec store (`internal/core/spec`) | Reads the native spec/task tree, newest-first; powers spec-essence | +| transcript reader | native transcript store (`internal/core/history`) | Reads the root-SHA-keyed local corpus; merge by timestamp/content hash when an imported specstory source is also present | | memory reader | `.abcd/memory/` | Reads the curated memory substrate (repo by default; see [`07-memory.md § 0`](07-memory.md#0-memory-scopes-and-routing)). **Read-only on any vendor harvest source** — see invariant below | | reviews reader | `.abcd/work/reviews/-/*.md` (charter grammar) + spec-tied reviews | Reads oracle/review artefacts written by the `oracle` seam's capture side; powers review-collator | | `claude_md` reader | `CLAUDE.md` + `git log -p CLAUDE.md` | Snapshot + history | diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index 10f4144e7..efe34e859 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -160,7 +160,7 @@ home: no registry package exists and no code refers to one. **Owed-review draining (staged)** is receipt gating in the `run` seam ([adr-27](../../decisions/adrs/0027-autonomous-run-pluggable-seam.md)). No `run` -seam ships, so nothing drains today. When it does, owed fidelity reviews drain at +seam ships, so nothing drains today. When it does, owed intent audits drain at the seam's iteration boundary: each iteration gates on a receipt and applies the safety guard, report-not-block, whichever adapter provides the loop. There is deliberately no autodrain config knob — receipt gating is part of the seam @@ -763,7 +763,8 @@ because siblings would run in parallel and share one stdin. that reach `bootstrap.sh` at all. The last three self-provision only when the plugin-root binary is missing, throttled by a `.bootstrap.attempt` marker within a ten-minute window, and then fall back to a PATH-resolved abcd that must be -absolute, outside the working directory, not world-writable, and recorded as this +absolute, outside the working directory, in a directory and a file that are not +world-writable, and recorded as this machine's own, before failing loudly. `SessionEnd` and `SubagentStop` are the two exceptions and download nothing at all: each fires where the harness cancels a slow hook rather than wait — one as the session exits, the other inside a live session as diff --git a/.abcd/development/brief/05-internals/04-universal-patterns.md b/.abcd/development/brief/05-internals/04-universal-patterns.md index 521a24ba2..240b4217d 100644 --- a/.abcd/development/brief/05-internals/04-universal-patterns.md +++ b/.abcd/development/brief/05-internals/04-universal-patterns.md @@ -141,7 +141,7 @@ This is the core internals story. **Every capability abcd could take from an ext | **run** | thin native Go loop (adr-27) | Claude Workflows, the companion harness's agent loop | | **scanner** | native secret/PII scan | gitleaks, Presidio, TruffleHog, … | -Each seam is a Go interface in `internal/adapter/` with a native implementation that ships in the binary; concrete external backends live behind the same interface, selected by config. Consumers in `internal/core` depend on the **interface**, never on a vendor — they consume "an oracle", "a transcript store", "a spec store", not "RepoPrompt" or "specstory". Adding a backend = implement the interface and register it in `internal/registry`; no edits to consumers. +Each seam is a Go interface in `internal/adapter/` with a native implementation that ships in the binary; concrete external backends live behind the same interface, selected by config. Consumers in `internal/core` depend on the **interface**, never on a vendor — they consume "an oracle", "a transcript store", "a spec store", not "RepoPrompt" or "specstory". Adding a backend = implement the interface and register it in a registry (the design target `internal/registry`, which does not exist yet); no edits to consumers. Of the five, only `scanner` is a directory under `internal/adapter/` in the tree; `oracle`, `history`, `spec` and `run` are design targets, each introduced by the phase that first consumes it, and today their native paths live in `internal/core` (`oracle`, `history`, `spec`, `implement`). `internal/adapter/` also holds adapters that are not capability seams: `gitleaks`, `hosting` and `openaiapi`. [`internal/README.md`](../../../../internal/README.md) § Planned seams is the gated list. **Backend resolution: the native default, config to override.** diff --git a/.abcd/development/brief/06-delivery/02-verification-matrix.md b/.abcd/development/brief/06-delivery/02-verification-matrix.md index a8720c4b9..7fdf29a7d 100644 --- a/.abcd/development/brief/06-delivery/02-verification-matrix.md +++ b/.abcd/development/brief/06-delivery/02-verification-matrix.md @@ -55,10 +55,10 @@ table is a gap in the table, never evidence that the capability is ungated.** | Intent ship | Shipping is spec-driven: `abcd spec close` moves the spec and, as its close-hook, the linked intent — on the close after which no OPEN spec names it, because an intent owns one or more specs (adr-2609151513118583). The intent must end up carrying a product impact, because that is what decides the release's version: a record that already declares one keeps it, one that declares none takes the impact on the close that ships, and a close that can name neither is refused before anything moves; `--impact` at an earlier close, which ships nothing, is refused too. There is no `intent ship`, and nothing runs the close for you, so an intent whose code is on the default branch with a spec still open ships with no changelog line | | Intent lifecycle hook | Closing the LAST open spec is what moves the linked intent; an earlier close moves nothing and names the specs that still hold it. **(staged)** the review queue that transition was to feed: nothing is enqueued or backfilled, and auto-running the auditor is deferred (spc-6 disowned it). The audit that ships is manual — `abcd intent audit` re-emits the request and `abcd intent audit ingest` writes the returned verdict into the intent's own Audit Notes | | Intent help | Bare `abcd intent` shows intents grouped by state with the next actions to take | -| Intent fidelity review (Role 1) | The shipped auditor prompt emits both halves: per-criterion verdicts over the acceptance criteria, each with a cited evidence pointer and a concerns verdict refused unless it names its concern, **and** the three-bucket prose audit. **(staged)** the boilerplate check over a spec's modification grammar: no such check is in the prompt or in any lint | +| Intent audit (Role 1) | The shipped auditor prompt emits both halves: per-criterion verdicts over the acceptance criteria, each with a cited evidence pointer and a concerns verdict refused unless it names its concern, **and** the three-bucket prose audit. **(staged)** the boilerplate check over a spec's modification grammar: no such check is in the prompt or in any lint | | Persona registry | The persona roster is a lint registry: the `persona_registry` rule matches a press-release quote attribution and refuses a name the roster does not carry, which is what keeps a real name out of a record. **(staged)** picking a persona from the roster and biasing that pick on a role hint: nothing selects | | Bidirectional link drift | A **blocker** when an intent's spec reference and the spec's intent reference disagree: the pair refuses to lint until they agree. It is part of the record lint, which runs in the pre-push gate and in CI; the committed pre-commit hook is the banned-names guard and runs no record lint | -| Press-release oracle audit | `abcd disembark press-release` composes deterministically from the packed brief and spine, or validates host-produced JSON under the cite-or-be-dropped validator. **(staged)** appending audit findings back into the composed press release | +| Press-release review | `abcd disembark press-release` composes deterministically from the packed brief and spine, or validates host-produced JSON under the cite-or-be-dropped validator. **(staged)** appending the lifeboat review's findings back into the composed press release | | Embark press-release interview | **(staged)** The first embark step shows the lifeboat press release and audit, the user confirms or amends, and the amended version becomes the embark contract. What ships is probe-then-write with the coverage blanks a human must answer surfaced first | | Capture write | `abcd capture ""` writes an issue into the open ledger with its frontmatter populated and reports the id, slug and severity; defaults applied per spc-21; the slug is derived from the text unless overridden | | Capture resolve | `abcd capture resolve` moves the issue to `resolved/` with its resolution and timestamp populated. A note and an impact are required, grounds are recorded when given, and the optional intent, spec, commit and release references write the structured provenance. An unknown id, a transition conflict and every other refusal of the request's input exit 2 with nothing written, and a fault exits 1 | diff --git a/.abcd/development/brief/glossary/README.md b/.abcd/development/brief/glossary/README.md index 547152c3a..28b93d921 100644 --- a/.abcd/development/brief/glossary/README.md +++ b/.abcd/development/brief/glossary/README.md @@ -211,7 +211,7 @@ The complete write-back protocol is a **design target** of `/abcd:intent grill`' | [brief](core/brief.md) | stable | The living root document that holds a project's purpose, constraints, and success criteria — always the project's current state, revised in place as the project moves. | | [bundle](core/bundle.md) | stable | Several intents that share one spec because they ship as one change; each member carries kind bundle-member and the bundle's name, and all ship together when the spec closes. | | [construal](core/construal.md) | stable | The statement of what the situation is being treated as, in one or two sentences, held at the top of the brief's framing chapter as the frame a widening reading reads against; one of adr-55's three framing surfaces, beside the committed glossary terms and the committed scope. The ledger context's entry governs the term inside the cold-reading experiment. | -| [disembark](core/disembark.md) | stable | The act of packing a lifeboat — `abcd disembark to ` reads a source repository without writing to it and distils its settled artefacts, decisions, and configuration into a portable lifeboat directory at a destination outside that repository, which a fresh context can later unpack via `/abcd:embark`. | +| [disembark](core/disembark.md) | stable | The act of packing a lifeboat — `abcd disembark pack ` reads a source repository without writing to it and distils its settled artefacts, decisions, and configuration into a portable lifeboat directory at a destination outside that repository, which a fresh context can later unpack via `/abcd:embark`. | | [intent](core/intent.md) | stable | A press-release-shaped description of a feature written before implementation begins, capturing the user problem, proposed solution, and success criteria. | | [ledger](core/ledger.md) | stable | An append-or-move store a command writes and a human reads back, the issue ledger under .abcd/work/issues/ when the word stands bare; inside the cold-reading experiment the word means the warm material and its stores, which the read-block keeps from a reading (the ledger context's entries govern that sense). Four further ledgers share the word and are always named in full. | | [lifeboat](core/lifeboat.md) | stable | A portable directory artefact packed by `/abcd:disembark` that captures the distilled knowledge and configuration of a source project so it can be unpacked into a fresh context by `/abcd:embark`. It always lands outside the source repository, at an operator-chosen destination. | diff --git a/.abcd/development/brief/glossary/core/disembark.md b/.abcd/development/brief/glossary/core/disembark.md index eb9753d35..54a83ceb0 100644 --- a/.abcd/development/brief/glossary/core/disembark.md +++ b/.abcd/development/brief/glossary/core/disembark.md @@ -1,7 +1,7 @@ --- term: disembark bounded_context: core -definition: The act of packing a lifeboat — `abcd disembark to ` reads a source repository without writing to it and distils its settled artefacts, decisions, and configuration into a portable lifeboat directory at a destination outside that repository, which a fresh context can later unpack via `/abcd:embark`. +definition: The act of packing a lifeboat — `abcd disembark pack ` reads a source repository without writing to it and distils its settled artefacts, decisions, and configuration into a portable lifeboat directory at a destination outside that repository, which a fresh context can later unpack via `/abcd:embark`. aliases: ["lifeboat packing", "disembarkation"] forbidden_synonyms: ["export", "backup", "dump", "snapshot"] status: stable @@ -19,7 +19,7 @@ versions: null proxy of a project's theory that can be carried across a session, machine, or team boundary. It takes the source repository as an argument and is **read-only and out-of-tree**: -`abcd disembark to ` reads the repository — any repository, including a dead +`abcd disembark pack ` reads the repository — any repository, including a dead or archived one abcd has never touched — and writes the lifeboat somewhere else. The source tree is never written to, so there is no in-tree lifeboat directory; the record of the run lands in the [voyage](voyage.md) log at the operator level instead. The destination is guarded by a safety diff --git a/.abcd/development/brief/glossary/core/lifeboat.md b/.abcd/development/brief/glossary/core/lifeboat.md index 2d6d99725..19e43e040 100644 --- a/.abcd/development/brief/glossary/core/lifeboat.md +++ b/.abcd/development/brief/glossary/core/lifeboat.md @@ -6,7 +6,7 @@ aliases: ["lifeboat artefact", "disembark artefact"] forbidden_synonyms: ["backup", "archive", "snapshot", "checkpoint"] status: stable introduced_in: phase-1 -starts_when: disembark (`abcd disembark to `) writes the artefact to the operator-chosen destination; the source repository is never written to. +starts_when: disembark (`abcd disembark pack `) writes the artefact to the operator-chosen destination; the source repository is never written to. ends_when: The lifeboat is unpacked by `/abcd:embark` into a target project, or discarded. not_to_be_confused_with: core/record-families versions: null @@ -40,7 +40,7 @@ flow-control connotations in the abcd pipeline context). ## Examples -- "Run `abcd disembark ../old-project to /tmp/project-lifeboat` to pack a lifeboat before +- "Run `abcd disembark pack ../old-project /tmp/project-lifeboat` to pack a lifeboat before starting the rebuild — the old repo is only read." - "The lifeboat at `/tmp/project-lifeboat/` was passed to `/abcd:embark` to bootstrap the new context." diff --git a/.abcd/development/brief/glossary/core/voyage.md b/.abcd/development/brief/glossary/core/voyage.md index 783b26882..26d5fe1b8 100644 --- a/.abcd/development/brief/glossary/core/voyage.md +++ b/.abcd/development/brief/glossary/core/voyage.md @@ -63,7 +63,7 @@ or a "sprint" (a time box, not a record of runs). | Phase | Condition | |-------|-----------| -| Starts when | The first `abcd disembark to ` creates `~/.abcd/voyage//` | +| Starts when | The first `abcd disembark pack ` creates `~/.abcd/voyage//` | | Ends when | Only if the operator deletes it — the log is appended to, never rewritten or truncated | ## Examples diff --git a/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md b/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md index 56bf0ecd9..d41908ca6 100644 --- a/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md +++ b/.abcd/development/intents/disciplines/itd-1-acceptance-gates.md @@ -27,7 +27,7 @@ This is a small schema bump with a large quality return. Every intent gets a mea ## What's In Scope - **`## Acceptance Criteria` section** required in every intent template (standalone, bundle-member, *and* discipline). At least one Given-When-Then bullet. The section header is fixed (parser depends on it). -- **Hard-block validation in `/abcd:intent plan`** — intent cannot transition `drafts/` → `planned/` (or `drafts/` → `disciplines/`) without at least one well-formed acceptance criterion. Lint code: `IL002` (delivered by spc-8; see `05-internals/06-lint.md`). +- **Hard-block validation in `/abcd:intent plan`** — intent cannot transition `drafts/` → `planned/` (or `drafts/` → `disciplines/`) without at least one well-formed acceptance criterion. Lint code: `IL002` (delivered by spc-8 (predecessor store); see `05-internals/06-lint.md`). - **`intent-auditor` single-document role** — when auditing a shipped intent, the agent emits a per-criterion verdict block into the intent's own `## Audit Notes` section. The writer maintains a single delimited `### itd-1 review ` block (machine-fenced so a repeat review *replaces* it in place — the section never accumulates stale blocks; git history is the prior-review trail): ``` ## Audit Notes @@ -42,7 +42,7 @@ This is a small schema bump with a large quality return. Every intent gets a mea Overall: MET / MET_WITH_CONCERNS / NOT_MET / INCONCLUSIVE ``` - This `## Audit Notes` write is the **verdict of record**; each run also writes a per-run forensic copy at `.abcd/logbook/audit/review-/report.{json,md}`. **spc-12 ships the manual reviewer** (`/abcd:intent audit `) plus the `## Audit Notes` / `review-` writers; **automatic invocation on the `planned → shipped` transition is deferred to the lifecycle-owning spec** (`spc-6`). Until that lands, a shipped intent's `## Audit Notes` is populated only when `/abcd:intent audit` is run by hand. + This `## Audit Notes` write is the **verdict of record**; each run also writes a per-run forensic copy at `.abcd/logbook/audit/review-/report.{json,md}`. **spc-12 (predecessor store) ships the manual reviewer** (`/abcd:intent audit `) plus the `## Audit Notes` / `review-` writers; **automatic invocation on the `planned → shipped` transition is deferred to the lifecycle-owning spec** (`spc-6`, predecessor store). Until that lands, a shipped intent's `## Audit Notes` is populated only when `/abcd:intent audit` is run by hand. - **Escalation states** — four states, lifted from PAUL. Binary pass/fail loses information; four states preserve nuance without exploding. - **Verdict family disjointness** (cross-referenced from [`05-internals/01-agents.md § Verdict-tag protocol`](../../brief/05-internals/01-agents.md#verdict-tag-protocol)). The four criterion verdicts above (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) score *promise vs reality on a shipped intent* — they belong to `intent-auditor`'s Role 1 output. They are **deliberately disjoint from review verdicts** (`SHIP` / `NEEDS_WORK` / `MAJOR_RETHINK`) which score *changes/runs* (oracle reviews of plans, implementations, completions; consumed by the native receipt schema validator). The two enums never mix — review verdicts emit on a *change*, criterion verdicts emit on a *promise*. This disjointness was reinforced 2026-05-08 when idea-4's pre-review draft conflated the two families ("NOT_MET on an agent run" — wrong; criterion verdicts apply to intents not agents). Closing-the-loop signals on agents (per Frontier Awareness, idea-4) MUST use canary/golden-test/operator-tagged failure signals, NOT spec-level criterion verdicts. - **Intent template update** — the intent template the binary scaffolds (`internal/core`) includes the `## Acceptance Criteria` section with one example criterion. The discipline template (separate file) includes the same section. diff --git a/.abcd/development/intents/disciplines/itd-37-modification-grammar.md b/.abcd/development/intents/disciplines/itd-37-modification-grammar.md index 944625725..cd959bc53 100644 --- a/.abcd/development/intents/disciplines/itd-37-modification-grammar.md +++ b/.abcd/development/intents/disciplines/itd-37-modification-grammar.md @@ -42,13 +42,13 @@ The discipline is named for what's actually new — **modification grammar**, no The semantic enforcement is genuine LLM-judgement work. The discipline owns the requirement explicitly — regex cannot catch boilerplate. -**`MG004` enforcement surface (made concrete by spc-12).** The timing is unchanged — `MG004` runs at plan-review and ship time — but the named surface is the abcd-owned CI / pre-commit path: the native disciplines lint/CI wrapper invoked from `.github/workflows/ci.yml`, **not** a plan-review-step integration. The Role 1 `MG004` pass emits a `PASS` / `FAIL` boilerplate verdict; the verdict lands in a per-run batch receipt at `.abcd/logbook/audit/spec-mg-/report.{json,md}` (native specs have no `## Audit Notes` section, so the verdict cannot land in-file). spc-12 ships the `MG004` judgement pass, its receipt writer, and the receipt schema; the CI wiring is added by spc-12 itself via the native disciplines lint. +**`MG004` enforcement surface (made concrete by spc-12 (predecessor store)).** The timing is unchanged — `MG004` runs at plan-review and ship time — but the named surface is the abcd-owned CI / pre-commit path: the native disciplines lint/CI wrapper invoked from `.github/workflows/ci.yml`, **not** a plan-review-step integration. The Role 1 `MG004` pass emits a `PASS` / `FAIL` boilerplate verdict; the verdict lands in a per-run batch receipt at `.abcd/logbook/audit/spec-mg-/report.{json,md}` (native specs have no `## Audit Notes` section, so the verdict cannot land in-file). spc-12 (predecessor store) ships the `MG004` judgement pass, its receipt writer, and the receipt schema; the CI wiring is added by spc-12 (predecessor store) itself via the native disciplines lint. **Why the cost is justified.** itd-37 is the first *expensive* discipline in abcd's stack (~15-30 min careful thought per spec, vs ~5 min for itd-1 / itd-5 / itd-36's mechanical gates). With itd-1 + itd-5 + itd-36 + itd-37 all live, every spec carries 4 discipline gates costing ~30-45 min total — real, but justified. The failure modes the disciplines prevent (specs without acceptance bars, agents without quality gates, specs without modification grammar, specs without provenance) compound exponentially as the corpus grows. Disciplines are a fixed per-spec tax; the failures are exponential. Trade favourably. **Why all specs, no "trivial" carve-out.** "Trivial" cannot be cleanly specified without inviting loophole-driven exemptions. A `## Modification Grammar` section that says *"this spec adds a config flag with no extension surface; modifications are limited to renaming the flag; the rule is: never bind config-flag names to public API"* is **valuable** — documenting absence-of-extension-points IS modification grammar. Required for all; trivial specs produce short Modification Grammar sections, not absent ones. -**Why memory routing as secondary index, not primary store.** The dominant access pattern is "I'm modifying spec spc-N, give me spc-N's modification grammar" — that's a lookup the spec file already serves trivially. Memory routing earns its keep on the long-tail: *"how has our modification grammar of dispatch evolved across spc-1, spc-3, spc-7?"* Cheap to add (`principle-distiller` already exists post-itd-36); worth it; not load-bearing for the common case. In-spec capture is the primary store. This is also why itd-36 is a `builds_on` edge, not a blocker: the capture + enforcement half (`MG001`-`MG004`, the Phase 0 discipline registration) ships independently of itd-36, and only the extraction-to-memory trigger waits on its substrate — the partial-ship fallback below is the designed behaviour, not a contingency. +**Why memory routing as secondary index, not primary store.** The dominant access pattern is "I'm modifying spec spc-N, give me spc-N's modification grammar" — that's a lookup the spec file already serves trivially. Memory routing earns its keep on the long-tail: *"how has our modification grammar of dispatch evolved across the predecessor store's spc-1, spc-3, spc-7?"* Cheap to add (`principle-distiller` already exists post-itd-36); worth it; not load-bearing for the common case. In-spec capture is the primary store. This is also why itd-36 is a `builds_on` edge, not a blocker: the capture + enforcement half (`MG001`-`MG004`, the Phase 0 discipline registration) ships independently of itd-36, and only the extraction-to-memory trigger waits on its substrate — the partial-ship fallback below is the designed behaviour, not a contingency. ## What's In Scope diff --git a/.abcd/development/intents/drafts/itd-10-purge-uninstall.md b/.abcd/development/intents/drafts/itd-10-purge-uninstall.md index e5b4a21b4..2616d6040 100644 --- a/.abcd/development/intents/drafts/itd-10-purge-uninstall.md +++ b/.abcd/development/intents/drafts/itd-10-purge-uninstall.md @@ -51,7 +51,7 @@ But for projects that genuinely leave abcd (acquired, deprecated, pivot), the li ## Open Questions -- Should `destroy` offer to back up `.abcd/lifeboat/` somewhere first (since it may be valuable independently)? +- ~~Should `destroy` offer to back up `.abcd/lifeboat/` somewhere first (since it may be valuable independently)?~~ Moot under adr-35: a lifeboat is written out of tree, to a destination `disembark pack` is handed, so `destroy` finds none inside the repository. - Same question for `.abcd/development/` (it's design history, not just runtime state)? - **Naming — resolved 2026-05-07.** Two-tier on `/abcd:ahoy`: `/abcd:ahoy uninstall` is reversible marker-only removal (re-running `ahoy` re-installs cleanly); the deeper destroy surfaces as `/abcd:ahoy destroy` — fits the nautical metaphor (scuttling a ship has a name and a weight) and the verb's danger is encoded in the verb itself. The `uninstall` description ("reversible marker-only removal") makes the distinction discoverable. Plan-review for this intent treats this naming as decided rather than re-opening. diff --git a/.abcd/development/intents/drafts/itd-13-scheduled-dev-sync.md b/.abcd/development/intents/drafts/itd-13-scheduled-dev-sync.md index b106430fa..c4857a26b 100644 --- a/.abcd/development/intents/drafts/itd-13-scheduled-dev-sync.md +++ b/.abcd/development/intents/drafts/itd-13-scheduled-dev-sync.md @@ -43,7 +43,7 @@ Once real usage patterns emerge, the cost of "stale `.abcd/work/` until next dis - **Given** a macOS user runs `/abcd:ahoy` on a fresh repo with `dev_sync.scheduled.enabled = true` in their config, **when** ahoy completes, **then** a launchd `~/Library/LaunchAgents/dev.abcd.devsync..plist` is installed (with explicit confirmation prompt) AND the user can verify it via `launchctl list | grep abcd`. - **Given** a Linux user runs `/abcd:ahoy` on the same config, **when** ahoy completes, **then** a cron entry is installed (with explicit confirmation) AND the user can verify it via `crontab -l`. - **Given** an installed scheduled `dev-sync` runs at its configured interval, **when** the run completes successfully, **then** it writes `.abcd/logbook/dev-sync//run-report.{json,md}` AND `abcd dev-sync status` reports the timestamp of the last successful run. -- **Given** a scheduled `dev-sync` run conflicts with an in-progress `/abcd:disembark to ` (file-system contention on `.abcd/work/`), **when** the scheduler fires, **then** the scheduled run detects the active disembark via a documented lock mechanism, skips its work, logs the skip-reason to its run report, and waits for the next interval. +- **Given** a scheduled `dev-sync` run conflicts with an in-progress `/abcd:disembark pack ` (file-system contention on `.abcd/work/`), **when** the scheduler fires, **then** the scheduled run detects the active disembark via a documented lock mechanism, skips its work, logs the skip-reason to its run report, and waits for the next interval. - **Given** the user runs `/abcd:ahoy uninstall`, **when** the sub-verb completes, **then** the launchd plist (or cron entry) is removed AND the user can verify its absence via the same `launchctl list` / `crontab -l` check. - **Given** the user changes `dev_sync.interval` in `.abcd/config.json` from `"1h"` to `"30m"`, **when** the next `/abcd:ahoy install` (idempotent re-install) runs, **then** the scheduled job's interval updates to 30 minutes AND the change is recorded in the install report. - **Given** a scheduled `dev-sync` run fails (a configured oracle adapter unreachable, memory lock contention, etc.), **when** the next scheduled fire occurs, **then** the failure is recorded in the run report, surfaced via `abcd dev-sync status` as "last run: failed (N intervals ago)", and the next run attempts independently — no exponential backoff that hides recurring failures. diff --git a/.abcd/development/intents/drafts/itd-15-self-dogfooded-sota-audit.md b/.abcd/development/intents/drafts/itd-15-self-dogfooded-sota-audit.md index ba77650c7..1755ded08 100644 --- a/.abcd/development/intents/drafts/itd-15-self-dogfooded-sota-audit.md +++ b/.abcd/development/intents/drafts/itd-15-self-dogfooded-sota-audit.md @@ -41,12 +41,12 @@ This intent closes the loop. Once abcd is mature enough to reliably self-disemba > _BDD format, per `itd-1-acceptance-gates`. These gates are checked by `intent-fidelity-reviewer` when this intent moves to `shipped/`._ -- **Given** abcd-cli itself runs `/abcd:disembark to `, **when** Pass C executes, **then** `prompt-sota-self-auditor` activates, reads every pair `(agents/.md, .abcd/development/research/prompting/agents/.md)`, and produces per-agent findings in `audit/prompt-sota-.{json,md}` covering alignment, drift, and recommendations. -- **Given** any other repo (not abcd-cli) runs `/abcd:disembark to `, **when** Pass C executes, **then** `prompt-sota-self-auditor` does NOT activate — the agent is gated by `config.json["meta"].project_name == "abcd"` (or equivalent self-detection) and is silent on third-party repos. +- **Given** abcd-cli itself runs `/abcd:disembark pack `, **when** Pass C executes, **then** `prompt-sota-self-auditor` activates, reads every pair `(agents/.md, .abcd/development/research/prompting/agents/.md)`, and produces per-agent findings in `audit/prompt-sota-.{json,md}` covering alignment, drift, and recommendations. +- **Given** any other repo (not abcd-cli) runs `/abcd:disembark pack `, **when** Pass C executes, **then** `prompt-sota-self-auditor` does NOT activate — the agent is gated by `config.json["meta"].project_name == "abcd"` (or equivalent self-detection) and is silent on third-party repos. - **Given** the audit produces findings, **when** the lifeboat is written, **then** the findings are present both in the disembark report ("Self-audit summary") AND as a separate lifeboat artefact (`audit/prompt-sota-.json`) so they survive embark to a downstream copy. - **Given** the audit's drift exceeds a configured threshold for any agent, **when** the disembark concludes, **then** the disembark report flags the lifeboat as "ship-with-audit-warning" but does NOT refuse to ship — auditing is informational, not gating. - **Given** an agent's research file is missing (no `.abcd/development/research/prompting/agents/.md` exists), **when** the auditor runs, **then** the agent is reported as "no research baseline" and a separate finding is emitted suggesting the user create the file rather than treating absence as drift. -- **Given** the audit's low-risk recommendations include typo fixes and footer updates, **when** the user runs `/abcd:disembark to --apply-self-audit`, **then** those low-risk changes are applied to the working tree as a separate commit BEFORE the lifeboat is packed AND high-risk recommendations remain in the report for human review. +- **Given** the audit's low-risk recommendations include typo fixes and footer updates, **when** the user runs `/abcd:disembark pack --apply-self-audit`, **then** those low-risk changes are applied to the working tree as a separate commit BEFORE the lifeboat is packed AND high-risk recommendations remain in the report for human review. ## Open Questions diff --git a/.abcd/development/intents/drafts/itd-19-stage-aware-behaviour.md b/.abcd/development/intents/drafts/itd-19-stage-aware-behaviour.md index 393c3cf27..147329671 100644 --- a/.abcd/development/intents/drafts/itd-19-stage-aware-behaviour.md +++ b/.abcd/development/intents/drafts/itd-19-stage-aware-behaviour.md @@ -50,7 +50,7 @@ The ABCDevelopment workflow has stages with genuinely different intent (Autonomo - **Given** a project moves from `Autonomous//` to `Validation//`, **when** the user re-runs `/abcd:ahoy install` in the new location, **then** stage detection identifies "Validation", proposes the Validation profile (visibility=public-prep, dev_sync=read-only, scan.deep=true, oracle=rp+codex), and surfaces a diff of what would change vs. the current `.abcd/config.json`. - **Given** the user has explicitly overridden a config setting (e.g., set `dev_sync.rp.enabled = false` in Autonomous), **when** the stage transitions and the new profile would re-enable that setting, **then** the user is asked specifically about that override (preserve / re-prompt-each / accept-new-default) rather than silently overwriting. - **Given** a Validation-stage project with `oracle.backend = "rp+codex"` requested by the profile, **when** RP is unavailable, **then** the project gracefully falls back per the resolution chain AND the disembark report flags "stage profile requested rp+codex but only Codex available". -- **Given** a stage-aware project, **when** `/abcd:disembark to ` runs, **then** the resulting lifeboat's `.abcd/config.json["meta"]` records the stage detected at disembark time so a downstream `/abcd:embark from ` can suggest the matching stage profile if its target location indicates the same stage. +- **Given** a stage-aware project, **when** `/abcd:disembark pack ` runs, **then** the resulting lifeboat's `.abcd/config.json["meta"]` records the stage detected at disembark time so a downstream `/abcd:embark from ` can suggest the matching stage profile if its target location indicates the same stage. ## Open Questions diff --git a/.abcd/development/intents/drafts/itd-8-with-code-bundling.md b/.abcd/development/intents/drafts/itd-8-with-code-bundling.md index db2f464ce..b688a33be 100644 --- a/.abcd/development/intents/drafts/itd-8-with-code-bundling.md +++ b/.abcd/development/intents/drafts/itd-8-with-code-bundling.md @@ -12,7 +12,7 @@ severity: minor ## Press Release -> **abcd lets you bundle source code into your lifeboats.** Running `/abcd:disembark to --with-code` packs not only the synthesised brief, principles, and reviews, but also a curated copy of the existing implementation. On the receiving end, `/abcd:embark from --with-code` unpacks both the design lessons AND the working code, so the new project starts running immediately rather than rebuilding from scratch. +> **abcd lets you bundle source code into your lifeboats.** Running `/abcd:disembark pack --with-code` packs not only the synthesised brief, principles, and reviews, but also a curated copy of the existing implementation. On the receiving end, `/abcd:embark from --with-code` unpacks both the design lessons AND the working code, so the new project starts running immediately rather than rebuilding from scratch. > > "We were running disembark for big rewrites where the lessons were the goal, but for some projects we just wanted to fork-and-improve," said Bob, staff engineer. "Now I can choose: rebuild clean, or carry the code over and refactor where it matters." @@ -24,7 +24,7 @@ Code bundling needs a thoughtful scope decision (source dirs only? everything tr ## What's In Scope -- `--with-code` flag on `/abcd:disembark to ` (pack code into lifeboat) +- `--with-code` flag on `/abcd:disembark pack ` (pack code into lifeboat) - `--with-code` flag on `/abcd:embark from ` (unpack code from lifeboat) - Code scope decision: source dirs / everything tracked / curator-driven / user-picked (one wins) - Updated lifeboat shape with `code/` subdirectory and manifest @@ -40,11 +40,11 @@ Code bundling needs a thoughtful scope decision (source dirs only? everything tr > _BDD format, per `itd-1-acceptance-gates`. These gates are checked by `intent-fidelity-reviewer` when this intent moves to `shipped/`._ -- **Given** a project with a `src/` directory and `/abcd:disembark to --with-code` is invoked, **when** disembark completes, **then** the lifeboat at `` contains a `code/` subdirectory holding the curated source AND a `code/_manifest.json` declaring per-file scope decision (`copy_verbatim` / `copy_redacted` / `excluded` with reason). +- **Given** a project with a `src/` directory and `/abcd:disembark pack --with-code` is invoked, **when** disembark completes, **then** the lifeboat at `` contains a `code/` subdirectory holding the curated source AND a `code/_manifest.json` declaring per-file scope decision (`copy_verbatim` / `copy_redacted` / `excluded` with reason). - **Given** a lessons-only lifeboat (no `code/` subdirectory), **when** the user runs `/abcd:embark from --with-code` against it, **then** the command warns "this lifeboat carries lessons only; nothing to unpack as code" and embarks the brief content normally without erroring. - **Given** a code-bundled lifeboat, **when** `/abcd:embark from --with-code` runs, **then** the unpacked repo contains the source under the documented destination (e.g., `src/` matching the disembark scope) AND the embark report records the per-file copy outcomes. -- **Given** the user declines code curation at disembark time (or runs `disembark to ` without `--with-code`), **when** disembark completes, **then** no `code/` directory is written to the lifeboat — the `--with-code` flag is the *only* path to code shipping; default behaviour stays lessons-only. -- **Given** `disembark to --with-code` is invoked on a repo where the curator-suggested defaults exclude a file the user wants included, **when** the user inspects the curator output interactively, **then** the user can override the per-file decision before disembark commits the lifeboat. +- **Given** the user declines code curation at disembark time (or runs `disembark pack ` without `--with-code`), **when** disembark completes, **then** no `code/` directory is written to the lifeboat — the `--with-code` flag is the *only* path to code shipping; default behaviour stays lessons-only. +- **Given** `disembark pack --with-code` is invoked on a repo where the curator-suggested defaults exclude a file the user wants included, **when** the user inspects the curator output interactively, **then** the user can override the per-file decision before disembark commits the lifeboat. - **Given** a code-bundled lifeboat, **when** `/abcd:launch ship` runs against it, **then** `code/` is *not* automatically promoted to the public `abcd/` repo — code shipping respects the launch payload manifest's default-deny semantics; explicit allow-list entries are required. ## Open Questions diff --git a/.abcd/development/intents/drafts/itd-9-schema-migration.md b/.abcd/development/intents/drafts/itd-9-schema-migration.md index 8d0ff2ad6..019e783c8 100644 --- a/.abcd/development/intents/drafts/itd-9-schema-migration.md +++ b/.abcd/development/intents/drafts/itd-9-schema-migration.md @@ -41,11 +41,11 @@ This intent commits abcd to taking lifeboat portability seriously: schemas evolv > _BDD format, per `itd-1-acceptance-gates`. These gates are checked by `intent-fidelity-reviewer` when this intent moves to `shipped/`._ - **Given** a lifeboat produced by an older abcd version with `schema_version: 1` on every JSON artefact, **when** the user runs `/abcd:embark from `, **then** embark detects the schema delta, runs the registered migrators, unpacks the lifeboat into the target repo, and writes a migration log to `embark-report.{json,md}` listing each schema upgrade applied. -- **Given** a lifeboat too old to migrate cleanly (e.g. its schema is below the lowest registered migrator's source version), **when** embark runs, **then** the command refuses with a "re-disembark required" error AND the error message includes the exact one-line command the user should run on the source repo (e.g., `cd && /abcd:disembark to home`). +- **Given** a lifeboat too old to migrate cleanly (e.g. its schema is below the lowest registered migrator's source version), **when** embark runs, **then** the command refuses with a "re-disembark required" error AND the error message includes the exact one-line command the user should run on the source repo (e.g., `/abcd:disembark pack `). - **Given** a newer-schema lifeboat, **when** an older abcd binary tries to embark from it, **then** the binary fails fast with a "lifeboat is from a newer abcd version; upgrade abcd to embark this lifeboat" message — two-way compatibility is explicitly not supported and the failure mode is clear. - **Given** a successful migration during embark, **when** the migration log is written, **then** it records: source schema version, target schema version, list of migrators applied (in order), per-artefact field changes, and any non-fatal warnings. - **Given** the registered v1→v2 migrator, **when** it runs against a v1 artefact, **then** it produces a v2 artefact that round-trips cleanly through the v2 schema validator (no unknown fields, no missing required fields, all enums valid). -- **Given** a lifeboat is migrated during embark, **when** the resulting `.abcd/development/voyage/embark/provenance.json` is written, **then** it records the migration history (`was_schema: 1`, `now_schema: 2`, `migrators_applied: [...]`) so future audits can reconstruct what was changed. +- **Given** a lifeboat is migrated during embark, **when** the resulting `~/.abcd/voyage//embark/provenance.json` (the operator-level voyage store adr-35 moved it to) is written, **then** it records the migration history (`was_schema: 1`, `now_schema: 2`, `migrators_applied: [...]`) so future audits can reconstruct what was changed. ## Open Questions diff --git a/.abcd/development/intents/planned/itd-24-reflect-command.md b/.abcd/development/intents/planned/itd-24-reflect-command.md index f6844687d..4b64a567e 100644 --- a/.abcd/development/intents/planned/itd-24-reflect-command.md +++ b/.abcd/development/intents/planned/itd-24-reflect-command.md @@ -52,7 +52,7 @@ We expect the value of a retrospective to be in the lifeboat: a new project that - Decisions made (architectural / design choices crystallised during the phase) - Metrics (intents shipped, audit-note severity distribution, time-to-ship if measurable) - **Output**: `.abcd/development/retrospectives//README.md` — a peer of `.abcd/development/intents/`, committed as part of the phase's permanent record. -- **Lifeboat integration**: `/abcd:disembark to ` packs *all* of the voyage's phase retrospectives into the lifeboat — the full reflection arc travels. `/abcd:embark from ` surfaces predecessor retrospectives during the press-release interview ("here's what the previous voyage learned about X — does that apply here?"). +- **Lifeboat integration**: `/abcd:disembark pack ` packs *all* of the voyage's phase retrospectives into the lifeboat — the full reflection arc travels. `/abcd:embark from ` surfaces predecessor retrospectives during the press-release interview ("here's what the previous voyage learned about X — does that apply here?"). - **Reference back to intents and the audit**: the retrospective links to the phase doc, to the intents the phase bundled, and to the phase audit; per-intent reviewer notes are referenced (not duplicated). - **`reflection-composer` agent** — runs the interview, drafts the structured output, asks clarifying questions when answers feel thin. @@ -74,7 +74,7 @@ None stated. - **Given** a completed phase with no phase audit yet recorded, **when** the persona runs `/abcd:reflect `, **then** the command reports the missing audit and offers to run the phase-fidelity-reviewer inline before continuing into the retrospective. - **Given** a phase doc that exists but has no spec carrying its `phase:` anchor, **when** the persona runs `/abcd:reflect `, **then** the command refuses with "no specs anchored to `` — nothing shipped to reflect on" and writes no output. - **Given** a draft retrospective with thin answers (e.g. "what went well: it worked"), **when** the agent drafts the output, **then** the agent surfaces the thinness as a clarifying question rather than committing the thin answer. -- **Given** the same repo's lifeboat is then packed via `/abcd:disembark to `, **when** the lifeboat is inspected, **then** every `.abcd/development/retrospectives//README.md` the voyage produced is included in the lifeboat artefact. +- **Given** the same repo's lifeboat is then packed via `/abcd:disembark pack `, **when** the lifeboat is inspected, **then** every `.abcd/development/retrospectives//README.md` the voyage produced is included in the lifeboat artefact. - **Given** a target repo embarked from a lifeboat that includes retrospectives, **when** `/abcd:embark from ` runs the press-release interview, **then** the persona is shown the few predecessor lessons ranked most like the new voyage's brief, with the rest as a list, and asked which apply. - **Given** an attempt to reflect on a phase whose specs are not all closed, **when** `/abcd:reflect ` runs, **then** the command warns the persona, lists the open specs anchored to that phase, and asks for confirmation to proceed anyway. - **Given** the last piece of work anchored to a phase closes, **when** that close completes, **then** abcd says once that a retrospective for the phase is owed and names the command, and says nothing further about it. @@ -96,7 +96,7 @@ _None open; decisions 1 and 2 settle the two this record carried (the reflection `/abcd:reflect` **cannot be planned until the phase-fidelity-reviewer ships** and its output artefact is stable and machine-readable. The reviewer is deferred in adr-9. Because `/abcd:reflect`'s core design is to *consume* the audit's per-bullet verdicts as interview seed material, and the audit-missing AC depends on running the reviewer inline, the command has no buildable contract until the reviewer's output format exists. `/abcd:intent plan itd-24` must not proceed while the phase-fidelity-reviewer remains unbuilt. -**Satisfied:** the stable machine-readable phase-fidelity output is provided by spc-66 (`phase_review_report.schema.json` + `.abcd/logbook/audit/phase-/report.{json,md}`), so this dependency is met and `/abcd:reflect` is planned under spc-83. V1 keys empty-phase detection off the spc-66 receipts (not a `phase:` anchor) and refuses on a missing/empty-audited receipt rather than offering the inline reviewer — see the `### Implementation notes (spc-83.3 — v1 scope)` block below. +**Satisfied:** the stable machine-readable phase-fidelity output is provided by spc-66 (predecessor store) (`phase_review_report.schema.json` + `.abcd/logbook/audit/phase-/report.{json,md}`), so this dependency is met and `/abcd:reflect` is planned under spc-83. V1 keys empty-phase detection off the spc-66 (predecessor store) receipts (not a `phase:` anchor) and refuses on a missing/empty-audited receipt rather than offering the inline reviewer — see the `### Implementation notes (spc-83.3 — v1 scope)` block below. ## Audit Notes @@ -110,9 +110,9 @@ reads them as deliberate v1 scope, not gaps: - **Missing-audit inline-reviewer offer → refusal (deferred).** The draft AC had the command "offer to run the phase-fidelity-reviewer inline" when no - audit exists. V1 instead **refuses** when no spc-66 phase-audit receipt exists + audit exists. V1 instead **refuses** when no spc-66 (predecessor store) phase-audit receipt exists for the named phase (and when the latest matching receipt is empty-audited). - Empty-phase detection keys off the spc-66 receipts, not a `phase:` spec anchor + Empty-phase detection keys off the spc-66 (predecessor store) receipts, not a `phase:` spec anchor (that anchor is deferred; phase membership is editorial). Running the reviewer inline from reflect is a recorded future extension. - **Open-spec warn/confirm (deferred).** The draft AC had reflect warn and ask @@ -121,9 +121,9 @@ reads them as deliberate v1 scope, not gaps: semantics above are the v1 gate. - **Phase-only grain, source links, lifeboat.** `/abcd:reflect ` is refused (phase-only grain). V1 links to the phase doc + audit report + member - specs only (no intent links — the spc-66 receipt carries no intent ids; + specs only (no intent links — the spc-66 (predecessor store) receipt carries no intent ids; recorded future extension). The lifeboat-packs-all-retrospectives requirement - is a DOCUMENTED forward requirement on the future disembark spec (spc-17 stubs), + is a DOCUMENTED forward requirement on the future disembark spec (spc-17 (predecessor store) stubs), not a behaviour this surface implements — recorded in the surface doc. The interview is a single seeded pass (per-bullet verdicts → five questions); diff --git a/.abcd/development/intents/planned/itd-50-loop-toward-acceptance.md b/.abcd/development/intents/planned/itd-50-loop-toward-acceptance.md index 485bc2476..32b7cfe8a 100644 --- a/.abcd/development/intents/planned/itd-50-loop-toward-acceptance.md +++ b/.abcd/development/intents/planned/itd-50-loop-toward-acceptance.md @@ -28,7 +28,7 @@ impact: additive ## Why This Matters -abcd's headline discipline is the audit loop: the dotted edge that compares delivered reality back against an intent's acceptance criteria. But in its current form the edge is **trigger-and-record, not enforcing**. When a spec closes, abcd moves the intent to `shipped/` and enqueues a review (spc-28); the `intent-fidelity-reviewer` (spc-12) emits per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) and writes them to the intent's `## Audit Notes`. A `NOT_MET` is faithfully recorded — and then nothing happens. The loop detects the miss but does not close it. +abcd's headline discipline is the audit loop: the dotted edge that compares delivered reality back against an intent's acceptance criteria. But in its current form the edge is **trigger-and-record, not enforcing**. When a spec closes, abcd moves the intent to `shipped/` and enqueues a review (spc-28, predecessor store); the `intent-fidelity-reviewer` (spc-12, predecessor store) emits per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) and writes them to the intent's `## Audit Notes`. A `NOT_MET` is faithfully recorded — and then nothing happens. The loop detects the miss but does not close it. This leaves two real gaps: @@ -51,7 +51,7 @@ This intent is **project-agnostic**: every abcd project ships intents whose deli - A new terminal audit outcome distinct from `NOT_MET`: *the intent as written cannot be met*. It bounds the loop (a loop-to-acceptance intent that exhausts its budget or is judged unachievable terminates here rather than thrashing). - `UNACHIEVABLE` **summons a replan**: it surfaces an explicit invitation for the product thinker and facilitator to revisit the intent together (re-open to `drafts/`, or a dedicated replan surface — decided at plan). It is never an automatic rollback and never a silent give-up. -- Generalises spc-31's one-off `HOLD` outcome (flag-for-follow-up, never auto-rollback) into a first-class loop-exit state. +- Generalises spc-31 (predecessor store)'s one-off `HOLD` outcome (flag-for-follow-up, never auto-rollback) into a first-class loop-exit state. ### Manual verification gated on machine-acceptance @@ -106,10 +106,10 @@ _None open; decisions 2 to 4 settle the four this record carried._ ## Related -- **spc-28** (intent lifecycle hook + review queue) — ships the on-close move + review *enqueue*; this intent is the policy layer that decides what happens to a queued/recorded verdict. -- **spc-12** (intent-fidelity-reviewer) — ships the Role 1 per-criterion verdicts (`MET`/`NOT_MET`/…) this loop consumes; this intent adds the terminal `UNACHIEVABLE` outcome the current enum lacks. +- **spc-28** (predecessor store) (intent lifecycle hook + review queue) — ships the on-close move + review *enqueue*; this intent is the policy layer that decides what happens to a queued/recorded verdict. +- **spc-12** (predecessor store) (intent-fidelity-reviewer) — ships the Role 1 per-criterion verdicts (`MET`/`NOT_MET`/…) this loop consumes; this intent adds the terminal `UNACHIEVABLE` outcome the current enum lacks. - **itd-47** (Codex leg in `_build_cli_oracle`) — `loop-to-acceptance` needs a reachable oracle to iterate headlessly; dependency, not scope. -- **spc-31** `HOLD` outcome (`desync_report.run_fn31_gates`) — the one-off "flag-for-follow-up, never rollback" precedent this intent generalises into a first-class loop-exit state. +- **spc-31** (predecessor store) `HOLD` outcome (`desync_report.run_fn31_gates`) — the one-off "flag-for-follow-up, never rollback" precedent this intent generalises into a first-class loop-exit state. - **itd-44** (fourth intent kind / decision) and **itd-43** (terminology) — the product-thinker / facilitator two-vocabulary split this intent leans on. - **a dated working-log entry (2026-06-02)** — the `[Design/Spec-candidate]` entry and the upstream `[Design/Decision-needed]` auto-drain recommendation that produced this intent; the auto-drainer is this intent's substrate. @@ -119,21 +119,21 @@ _None open; decisions 2 to 4 settle the four this record carried._ ### Predecessor AC reconciliation (spc-52) -In the predecessor implementation each acceptance criterion above is satisfied and the open questions are resolved at its plan + build time; the table attributes which spc-52 task owns which behaviour. +In the predecessor implementation each acceptance criterion above is satisfied and the open questions are resolved at its plan + build time; the table attributes which spc-52 (predecessor store) task owns which behaviour. | itd-50 Acceptance Criterion | Status | Where delivered | |---|---|---| -| `loop-to-acceptance` re-opens + re-reviews a `NOT_MET` until `MET` or budget exhausted | Satisfied | spc-52.2 — `audit_loop_policy.decide_loop_action` + the queue-layer re-enqueue in `review_queue._apply_loop_policy` (reopen de-risk gate: no clean native spec-store reopen surface, so the loop re-enqueues at the queue layer) | -| budget-exhausted / unmeetable → `UNACHIEVABLE` rollup, loop stops, written explanation + replan invitation naming both roles, no rollback, no machine-authored replan | Satisfied | spc-52.2 — `UNACHIEVABLE` is a rollup-layer terminal; `write_replan_invitation` writes the `why-unachievable` block; intent stays `shipped/` | -| machine criteria all `MET` → manual-verification offered, recorded as a receipt distinct from the machine verdict | Satisfied | spc-52.3 — `verification_receipt.write_receipt` (`offered` receipt under `.abcd/logbook/audit/verify-/`, never merged into `## Audit Notes`) | -| `MET` but product thinker rejects (wrong criteria) → replan path, NOT a synthetic `NOT_MET` | Satisfied | spc-52.3 — `verification_receipt.record_rejection_replan` re-enters the SHARED replan surface (one writer, two entry points); test-pinned that no `NOT_MET` is written | -| machine criteria NOT all `MET` → product thinker not asked (loop / replan runs first) | Satisfied | spc-52.3 — `is_acceptance_eligible` gate; the gate API refuses an ineligible rollup (premature-suppression test) | -| `INCONCLUSIVE` → recorded as today, no summons, no replan | Satisfied | spc-52.2 — `decide_loop_action` fail-closed branch; never flips to `UNACHIEVABLE` | -| `record-only` (default) → behaviour unchanged from today | Satisfied | spc-52.1 — absent mode resolves to `record-only`; queue-entry shape regression-pinned | -| `UNACHIEVABLE` / rejection seeds `/abcd:intent grill` | Satisfied | spc-52.2 / .3 — both replan blocks name the grill seed | -| on-close hook stays a pure data function (no subprocess / oracle) | Satisfied | The mode logic rides the spc-43 drainer / policy layer; `intent_lifecycle` is untouched by the loop | - -**Open questions (predecessor answers, to re-adjudicate at spec time):** loop budget = one re-open+re-review cycle per iteration, default `3` (spc-52.1 § Decision context); replan surface = no `drafts/` move, a `why-unachievable` + replan block in `## Audit Notes` with the intent kept in `shipped/` (spc-52.2 R4); manual-verification sign-off = the receipt schema `{intent_id, machine_rollup, state, justification?, recorded_by_role, ts}` with the `rejected_wrong_criteria` state carrying the justification to the shared replan surface (spc-52.3 R5). +| `loop-to-acceptance` re-opens + re-reviews a `NOT_MET` until `MET` or budget exhausted | Satisfied | spc-52.2 (predecessor store) — `audit_loop_policy.decide_loop_action` + the queue-layer re-enqueue in `review_queue._apply_loop_policy` (reopen de-risk gate: no clean native spec-store reopen surface, so the loop re-enqueues at the queue layer) | +| budget-exhausted / unmeetable → `UNACHIEVABLE` rollup, loop stops, written explanation + replan invitation naming both roles, no rollback, no machine-authored replan | Satisfied | spc-52.2 (predecessor store) — `UNACHIEVABLE` is a rollup-layer terminal; `write_replan_invitation` writes the `why-unachievable` block; intent stays `shipped/` | +| machine criteria all `MET` → manual-verification offered, recorded as a receipt distinct from the machine verdict | Satisfied | spc-52.3 (predecessor store) — `verification_receipt.write_receipt` (`offered` receipt under `.abcd/logbook/audit/verify-/`, never merged into `## Audit Notes`) | +| `MET` but product thinker rejects (wrong criteria) → replan path, NOT a synthetic `NOT_MET` | Satisfied | spc-52.3 (predecessor store) — `verification_receipt.record_rejection_replan` re-enters the SHARED replan surface (one writer, two entry points); test-pinned that no `NOT_MET` is written | +| machine criteria NOT all `MET` → product thinker not asked (loop / replan runs first) | Satisfied | spc-52.3 (predecessor store) — `is_acceptance_eligible` gate; the gate API refuses an ineligible rollup (premature-suppression test) | +| `INCONCLUSIVE` → recorded as today, no summons, no replan | Satisfied | spc-52.2 (predecessor store) — `decide_loop_action` fail-closed branch; never flips to `UNACHIEVABLE` | +| `record-only` (default) → behaviour unchanged from today | Satisfied | spc-52.1 (predecessor store) — absent mode resolves to `record-only`; queue-entry shape regression-pinned | +| `UNACHIEVABLE` / rejection seeds `/abcd:intent grill` | Satisfied | spc-52.2 (predecessor store) / .3 — both replan blocks name the grill seed | +| on-close hook stays a pure data function (no subprocess / oracle) | Satisfied | The mode logic rides the spc-43 (predecessor store) drainer / policy layer; `intent_lifecycle` is untouched by the loop | + +**Open questions (predecessor answers, to re-adjudicate at spec time):** loop budget = one re-open+re-review cycle per iteration, default `3` (spc-52.1 (predecessor store) § Decision context); replan surface = no `drafts/` move, a `why-unachievable` + replan block in `## Audit Notes` with the intent kept in `shipped/` (spc-52.2 (predecessor store) R4); manual-verification sign-off = the receipt schema `{intent_id, machine_rollup, state, justification?, recorded_by_role, ts}` with the `rejected_wrong_criteria` state carrying the justification to the shared replan surface (spc-52.3 (predecessor store) R5). ## Grounds diff --git a/.abcd/development/intents/planned/itd-6-rp-mcp-only-integration.md b/.abcd/development/intents/planned/itd-6-rp-mcp-only-integration.md index b4b112a90..00e6a810b 100644 --- a/.abcd/development/intents/planned/itd-6-rp-mcp-only-integration.md +++ b/.abcd/development/intents/planned/itd-6-rp-mcp-only-integration.md @@ -5,7 +5,7 @@ spec_id: spc-2609211950427074 kind: standalone suggested_kind: null reclassification_history: [] -builds_on: [itd-2] +builds_on: [itd-2, itd-2609201916151817, itd-2609170822093401, itd-2609201925079472, itd-2609201916056194] severity: minor impact: additive --- @@ -21,7 +21,7 @@ impact: additive > **abcd has exactly one integration with RepoPrompt: the MCP API.** abcd never picks an `oracle`, never reads RP's preset selection, never spawns its own subprocess for code review. It calls RP via MCP and RP uses whatever `oracle` the persona has configured for whatever task — Claude via the persona's subscription, Codex via the persona's subscription, Gemini, any preset RP knows. The persona configures `oracle` backends inside RP once; abcd uses them forever. Zero abcd-side `oracle` logic, zero "which preset?" prompts, zero hard-coded routing. > -> **Status (post-spc-5): the RP MCP bridge/foundation is implemented.** spc-5 declares the typed `RPUnavailable` error (in `internal/core/...`) and delivers a concrete `MCPBridge` — the ADR-02 spawn implementation shipped, plus the ADR-03 host-reuse hook for host-connected Claude Code. What this intent describes as the *three-step cascade* (RP MCP → Codex CLI → in-session subagent), the one-time ahoy RP setup discovery, and the non-Mac flow are **follow-up work, deferred** beyond spc-5 — see the Implementation status section. This is "the bridge is built and typed", not "the RP MCP path is fully wired end-to-end". +> **Status: no part of the RP MCP route is built.** The `RPUnavailable` error, the `MCPBridge` and the `oracle.py` audit-fix loop this record names belong to an earlier Python lineage: its spec `spc-5-rp-mcp-integration-declare` (not the `spc-5` in this repository's spec store) and its ADR-02 and ADR-03 (not this repository's adr-2 and adr-3). None of them is in this binary, and `go.mod` carries no MCP dependency. The re-filed scope, [spc-2609211950427074](../../specs/open/spc-2609211950427074-rp-mcp-only-integration.md), builds the route from nothing. See the Implementation status section. > > "I had wired up Claude, Codex, and Gemini in RP with task-specific presets," said Bob, staff engineer. "I'd worried abcd would keep asking me which to use. The RP MCP bridge just calls RP; when RP is not reachable it raises a typed `RPUnavailable` so the tooling can react cleanly instead of guessing. RP picks the `oracle`. I don't think about it." @@ -84,10 +84,10 @@ _None open._ ## Resolved (post-spc-5) -These questions were settled by the spc-5 spec and the Phase 0 harness-interface research note ([`01-harness-interface.md`](../../research/notes/01-harness-interface.md)), together with the spc-5 `.6` host-reuse / failure-mapping work. +These questions were settled against the earlier Python lineage's design — its spc-5 spec and its ADR-02 and ADR-03, not this repository's spc-5, adr-2 and adr-3 — and the Phase 0 harness-interface research note ([`01-harness-interface.md`](../../research/notes/01-harness-interface.md)). The answers stand as design input for the re-filed adapter; the `MCPBridge`, `McpResult`, `RPUnavailable` and `oracle.py` they name are that lineage's, and none of them is in this binary. - **Does RP MCP support the long-running, async-result pattern abcd needs (e.g., a 5-minute Carmack review)? Or is it strictly synchronous within an MCP call lifetime?** - Resolved by ADR-02 § 4: the `MCPBridge` contract is synchronous within an MCP call lifetime — `mcp_call` blocks for the call's duration. There is no async-result handle. The long-running case is handled by a generous per-tool `call_timeout_s` budget (`oracle_send` / `context_builder` get 600 s) inside one held-warm stdio session, not by an async poll. spc-5's concrete `MCPBridge` implements exactly this. + Resolved by ADR-02 § 4: the `MCPBridge` contract is synchronous within an MCP call lifetime — `mcp_call` blocks for the call's duration. There is no async-result handle. The long-running case is handled by a generous per-tool `call_timeout_s` budget (`oracle_send` / `context_builder` get 600 s) inside one held-warm stdio session, not by an async poll. - **If RP MCP returns a chat ID for long-running work, how does abcd poll/listen for completion?** Resolved by ADR-02 §§ 3–4: there is no polling. The call is synchronous; `mcp_call` returns when the tool call returns. The `chat_id` on `McpResult` is for *same-session re-review threading*, not completion polling. The async-vs-sync decision referenced for "Task 5's harness.py" is settled — the harness method stays synchronous (ADR-01 § 3 lock), and the concrete sync↔async bridge is internal to spc-5's `MCPBridge`. - **Chat identity and continuation — what does a `chat_id` mean, and can a chat be resumed across `abcd-cli` invocations?** @@ -102,38 +102,43 @@ These questions were settled by the spc-5 spec and the Phase 0 harness-interface mid-call transport failure), `oracle.py` routes to `dispatch_agent(agent_name="codex", ...)`. No silent retry within the RP transport; fall-through IS the retry (to the next cascade level). Timeout defaults: `startup_timeout_s = 10.0 s` (combined spawn + initialize); `call_timeout_s` - default 30 s with per-tool overrides. See ADR-02 §§ 4–6. + default 30 s with per-tool overrides. See ADR-02 §§ 4–6. The re-filed Decisions (3) replace the + Codex step: an unreachable RepoPrompt falls back to the host's own agent, with the receipt + saying so, and this repository has no `oracle.py`. ## Implementation status -_Added post-spc-5. The spc-5 spec (`spc-5-rp-mcp-integration-declare`) implemented the RP -MCP bridge/foundation — the typed `RPUnavailable` error (`internal/core/...`, -spc-5 `.1`), the concrete `MCPBridge` ADR-02 spawn implementation (spc-5 `.2`/`.5`), and the -ADR-03 host-reuse code path (spc-5 `.6`/`.7`). Operational availability under host-connected -Claude Code is still subject to RP's GUI approval gate — an approval denial surfaces as the -typed `RPUnavailable` rather than a hang. spc-5 does **not** complete this intent end-to-end: -the following acceptance criteria are explicitly deferred to follow-up work._ - -- **AC#3 — Verdict-direction (both-directions accepted across audit-fix iterations): DEFERRED.** - Requires the `oracle.py` audit-fix loop. spc-5 ships only the `MCPBridge` transport, not the - `re_audit` caller. Follow-up target: the `oracle.py` cascade spec (downstream of spc-5). -- **AC#4 — Three-step cascade (RP MCP → Codex CLI → in-session subagent): DEFERRED.** - spc-5 delivers the RP transport and the typed `RPUnavailable` signal that the cascade catches, - but the cascade itself (Codex CLI fallthrough, in-session subagent fallback, run-log - surfacing of the serving backend) is not in spc-5. Follow-up target: the `oracle.py` / - itd-2 cascade spec. -- **AC#5 — Ahoy setup discovery (RP MCP config-path detection + `oracle.backend` lock): DEFERRED.** - spc-5 does not touch `/abcd:ahoy`. The MCP config-resolution order is specified in ADR-02 § 1, - but the ahoy-side discovery, `.abcd/config.json` write, and one-time hint are follow-up work. - Follow-up target: the ahoy / setup-discovery spec. -- **AC#6 — Non-Mac flow (Codex-only users never prompted about RP): DEFERRED.** - Depends on AC#4's cascade and AC#5's setup discovery being in place. Follow-up target: - the same cascade + setup-discovery follow-up epics. - -AC#1 (MCP-only call path, no `claude -p` spawn) and AC#2 (`chat_id` threading within one -invocation) are *foundationally* satisfied by spc-5's `MCPBridge` + ADR-02 § 3, but their -end-to-end gate runs only when the `oracle.py` callers above land — they are not claimed -shipped here. +_Prerequisites, recorded 2026-09-29 (iss-2609240227236354)._ The readiness gate +reads this record READY, but its spec (spc-2609211950427074) stands on four +things the tree does not hold yet, and `builds_on` names the four intents +behind them: + +- **The validator stage the adapter implements.** `abcd build` ships its loop's + first pieces, and the validators are a later piece of the same spec + (itd-2609201916151817, spec spc-2609202134338445 open; see + `internal/core/implement/loop/loop.go`). +- **The `oracle.review` route.** The layered configuration reader + (`internal/core/layered`) exists; the review-route intents it serves, + itd-2609170822093401 and itd-2609201925079472, are still planned with specs + open. +- **The command-line runner** the adapters chapter entry sits beside + (itd-2609201916056194, spec spc-2609221533057881 open). +- **An MCP client.** The spec's Approach names one as a new dependency, subject + to the new-dependency sign-off; `go.mod` carries none. + +The readiness gate does not read `builds_on`, so these edges inform a reader +and a run that picks this record; they do not change the gate's verdict. + +_Nothing of this intent is built._ A grep for `MCPBridge`, `RPUnavailable` and `RepoPrompt` +over `internal/` and `cmd/` finds only the scanner's RepoPrompt session-key pattern +(`internal/adapter/scanner/patterns.go`), a guard corpus line, and the doc comment of the +configuration layer (`internal/core/layered`) naming `oracle.review` as a consumer it serves; +`go.mod` carries no MCP dependency. The bridge, the typed error +and the host-reuse path an earlier Python lineage's `spc-5-rp-mcp-integration-declare` describes +belong to that lineage, not to this binary, so no part of the route is a foundation to build on. +The four acceptance criteria above are the re-filed set, and +[spc-2609211950427074](../../specs/open/spc-2609211950427074-rp-mcp-only-integration.md) +carries all of them. ## Audit Notes diff --git a/.abcd/development/intents/planned/itd-7-rp-workspace-portability.md b/.abcd/development/intents/planned/itd-7-rp-workspace-portability.md index 53de19a46..fc758e678 100644 --- a/.abcd/development/intents/planned/itd-7-rp-workspace-portability.md +++ b/.abcd/development/intents/planned/itd-7-rp-workspace-portability.md @@ -11,6 +11,8 @@ severity: minor # Lifeboats Carry RepoPrompt Workspace Definitions Forward > **Waiting on itd-6** (ruled 2026-09-21 by the product thinker): the RepoPrompt route is one opt-in reviewer adapter now; this record is planned as written and gets its spec after that adapter ships. It is not in the autonomous run. +> +> **Two more prerequisites do not exist yet** (recorded 2026-09-29, iss-2609240227447110). The pull hangs on `abcd dev-sync`, which has no verb and no code: the command tree carries none, and the only record of it is the draft itd-13 (scheduled dev-sync). The lifeboat route is missing too: embark writes only the four record families in `embarkFamilies` (`internal/core/lifeboat/embark_types.go`: ADRs, issues, intents and specs) and reports every other file as one it does not write, so `.abcd/rp/workspace.json` has no path from a lifeboat into a target repository, as acceptance criteria 6 and 7 (the two embark criteria) require. Specifying this record needs both, beside itd-6. ## Press Release diff --git a/.abcd/development/intents/shipped/itd-48-intent-fidelity-reviewer-roles-2-3.md b/.abcd/development/intents/shipped/itd-48-intent-fidelity-reviewer-roles-2-3.md index 372bddfe1..dc619f089 100644 --- a/.abcd/development/intents/shipped/itd-48-intent-fidelity-reviewer-roles-2-3.md +++ b/.abcd/development/intents/shipped/itd-48-intent-fidelity-reviewer-roles-2-3.md @@ -31,11 +31,11 @@ The `intent-fidelity-reviewer` agent is named in `docs/reference/commands.md` as | Verb | Role | Status | |---|---|---| -| `review ` | Role 1 — per-intent fidelity | **Shipped (spc-12)** | +| `review ` | Role 1 — per-intent fidelity | **Shipped (spc-12, predecessor store)** | | `consistency []` | Role 2 — cross-doc fidelity | **Documented, not built** | | `shape []` | Role 3 — kind classification | **Documented, not built** | -A 2026-05-16 working-log entry named this gap: "No flow-next spec owns `internal/core/lint` or the `intent-fidelity-reviewer` agent" — partially addressed by spc-12 for Role 1, but Roles 2 and 3 still have no owner. spc-12's spec explicitly bounded itself to Role 1; the other two roles "are NOT in scope: they are a later Pass A/B/C agent spec" (spc-12 `## Overview`). +A 2026-05-16 working-log entry named this gap: "No flow-next spec owns `internal/core/lint` or the `intent-fidelity-reviewer` agent" — partially addressed by spc-12 (predecessor store) for Role 1, but Roles 2 and 3 still have no owner. spc-12 (predecessor store)'s spec explicitly bounded itself to Role 1; the other two roles "are NOT in scope: they are a later Pass A/B/C agent spec" (spc-12 (predecessor store) `## Overview`). This intent ships those later epics as a **standalone intent whose scope covers both roles** — the two roles share substrate that should land together: @@ -69,7 +69,7 @@ The intent is project-agnostic: every abcd project that uses the intent corpus b routing is stubbed). - Findings emitted as structured records grouped by judgement category (terminology drift, premise contradictions, scope leakage, sequencing - impossibilities, naming conflicts) — spc-29 ships finding categories, + impossibilities, naming conflicts) — spc-29 (predecessor store) ships finding categories, not mechanical lint codes. Mechanical cross-doc categories (schema/state contradictions, reference rot, acknowledgement gaps) and any associated lint-code namespace are deferred to a follow-up @@ -90,7 +90,7 @@ The intent is project-agnostic: every abcd project that uses the intent corpus b `suggestions[]` (matched by `finding_signature`). - Implement `/abcd:intent shape []` routing as an **on-demand** verb. Continuous pre-commit shape scanning is **deferred** to a - follow-up intent — spc-29 ships only the on-demand surface. + follow-up intent — spc-29 (predecessor store) ships only the on-demand surface. - Pairs with `/abcd:intent reclassify` (already documented) — when Role 3 surfaces a finding, `reclassify` is the action verb that commits it. - Reports land at `.abcd/logbook/audit/shape-/report.{json,md}`. @@ -98,24 +98,24 @@ The intent is project-agnostic: every abcd project that uses the intent corpus b `{kind_change, bundle, supersession}` with the matching arm fields (`current_kind` + `suggested_kind`, `bundle_members`, or `superseded_by`); scoped runs additionally emit the - `KIND_OK` / `KIND_DRIFT` / `INCONCLUSIVE` `scoped_verdict`. spc-29 does + `KIND_OK` / `KIND_DRIFT` / `INCONCLUSIVE` `scoped_verdict`. spc-29 (predecessor store) does not introduce a mechanical lint-code namespace for this persona. ### Shared -- Both roles share spc-12's testing pattern: golden fixtures + at least one +- Both roles share spc-12 (predecessor store)'s testing pattern: golden fixtures + at least one injection-canary per role (per itd-5). - Both roles call `_build_cli_oracle()` from itd-47's extended version, so they run in headless Ralph mode. - **Pre-commit hook wiring is deferred** to a follow-up intent for both - roles. spc-29 ships the on-demand verbs (`/abcd:intent consistency` and + roles. spc-29 (predecessor store) ships the on-demand verbs (`/abcd:intent consistency` and `/abcd:intent shape`) only; the previous draft's promise of a `intent-fidelity-consistency` pre-commit hook and Role 3's "runs continuously in pre-commit" surface are explicitly out of scope. ## What's Out Of Scope -- **Role 1 modifications.** spc-12 shipped Role 1; this intent does not +- **Role 1 modifications.** spc-12 (predecessor store) shipped Role 1; this intent does not edit it. - **`lifeboat-oracle` agent.** itd-5's named reviewer; out of scope here. - **Auto-fix for findings.** Role 2 and Role 3 surface findings; a @@ -151,38 +151,38 @@ Ruled by the product thinker on 2026-09-21, in the interview that gave this inte _None open; the standalone-versus-two question this record carried is moot with one role left._ -## Routed Deferrals (spc-33) +## Routed Deferrals (spc-33, predecessor store) -spc-33's Phase 3→4 cleanup sweep routed its cluster-A and G1 deferrals into this +spc-33 (predecessor store)'s Phase 3→4 cleanup sweep routed its cluster-A and G1 deferrals into this intent (the `routed_from` frontmatter backlinks). This intent shipped the consistency pass alone and none of the five, so they are tracked by the ledger record iss-2609260926323349, a future-work seed that names them and hands the Role-3 item to the kinds lint in itd-34. The list below is what was routed here, kept as the record of that routing, not as scope this intent delivered: -- **`spc-33:A1`** — Role-2 mechanical half: schema/state contradictions, +- **`spc-33:A1`** (predecessor store) — Role-2 mechanical half: schema/state contradictions, reference rot, acknowledgement gaps → `internal/core/lint --cross-doc` lint codes `XD002` / `XD006` / `XD007` (the mechanical cross-doc categories this intent defers under "Open Questions → Mechanical Role 2 categories"). -- **`spc-33:A2`** — Role-2 pre-commit hook (blocking-vs-advisory policy) — the +- **`spc-33:A2`** (predecessor store) — Role-2 pre-commit hook (blocking-vs-advisory policy) — the hook wiring this intent lists as deferred under "Shared → Pre-commit hook wiring is deferred". -- **`spc-33:A3`** — Role-3 pre-commit scheduling: the `mode="pre_commit"` seam +- **`spc-33:A3`** (predecessor store) — Role-3 pre-commit scheduling: the `mode="pre_commit"` seam exists; the hook wrapper + per-commit-cost policy is the deferred follow-up. -- **`spc-33:A4`** — chunked corpus review. **DORMANT — triggered-by +- **`spc-33:A4`** (predecessor store) — chunked corpus review. **DORMANT — triggered-by `bundle_overflow: true`.** The Role-2 collector / Role-3 classifier set a `bundle_overflow` manifest flag on overflow and stop; this item activates ONLY when a persisted report actually shows `bundle_overflow: true`. Routing it here does NOT make it active implementation scope. -- **`spc-33:G1`** — consistency-report reconciliation key non-convergence: the +- **`spc-33:G1`** (predecessor store) — consistency-report reconciliation key non-convergence: the R1 `finding_id` recipe is prose-derived and re-hashes differently across re-runs. Fix is structural-key / deterministic-decode / an `addressed` report - state on `consistency_report.schema.json` — spc-29-owned (this intent owns the + state on `consistency_report.schema.json` — owned by the predecessor store's spc-29 (this intent owns the reviewer + the consistency-report contract). ## Related -- **spc-12** (`intent-fidelity-reviewer` agent — Role 1) — the foundation +- **spc-12** (predecessor store) (`intent-fidelity-reviewer` agent — Role 1) — the foundation this intent extends, via the shared agent file. - **itd-47** (autonomous-mode oracle gates) — precondition. Roles 2 and 3 need headless oracle access; itd-47 ships it. diff --git a/.abcd/development/intents/shipped/itd-53-review-queue-auto-drain-fidelity-gate.md b/.abcd/development/intents/shipped/itd-53-review-queue-auto-drain-fidelity-gate.md index 86b0f8256..14d34b897 100644 --- a/.abcd/development/intents/shipped/itd-53-review-queue-auto-drain-fidelity-gate.md +++ b/.abcd/development/intents/shipped/itd-53-review-queue-auto-drain-fidelity-gate.md @@ -132,8 +132,8 @@ Gap audit: - Source: design discussion on the audit-loop enforcement design (a dated working-log entry, 2026-06-02, "should spec-close auto-run review" — resolved NO; add a drainer instead). -- Touches: the pure on-close lifecycle hook (spc-28) and the review-queue - drain/claim machinery; the fidelity reviewer (spc-12) is the run target. +- Touches: the pure on-close lifecycle hook (spc-28, predecessor store) and the review-queue + drain/claim machinery; the fidelity reviewer (spc-12, predecessor store) is the run target. ## Grounds diff --git a/.abcd/development/research/prompting/README.md b/.abcd/development/research/prompting/README.md index df505b0ab..cc96048ca 100644 --- a/.abcd/development/research/prompting/README.md +++ b/.abcd/development/research/prompting/README.md @@ -7,6 +7,6 @@ harden into the shipped prompt stack. | Path | What it holds | |---|---| | [`01-general-best-practices.md`](01-general-best-practices.md) | General prompting SOTA — the baseline for all abcd agents | -| [`agents/`](agents) | Per-agent prompt drafts (chat-distiller, embark-scaffolder, intent-fidelity-reviewer, …) | +| [`agents/`](agents) | Per-agent prompt research: two design targets not yet built (chat-distiller, embark-scaffolder) and the shipped intent auditor under its earlier name (intent-fidelity-reviewer); the [inventory](agents/README.md#inventory) states each file's standing | For the sibling research areas, see the [research README](../README.md). diff --git a/.abcd/development/research/prompting/agents/README.md b/.abcd/development/research/prompting/agents/README.md index 55f66729e..3d64cb975 100644 --- a/.abcd/development/research/prompting/agents/README.md +++ b/.abcd/development/research/prompting/agents/README.md @@ -2,11 +2,11 @@ > Per the brief (§ "Research-driven prompts"): per-agent SOTA research is **task #1 of each agent's epic**. The output lives here as `.md` and references the baseline at [`../01-general-best-practices.md`](../01-general-best-practices.md). > -> **Role.** Same as the baseline: research is the **gate** (audit reference for `lifeboat-oracle` and the prompt linter), not the **source** (prompt template). The author writes the agent's prompt informed by the research; the oracle audits alignment. +> **Role.** Same as the baseline: research is the **gate** (audit reference for `lifeboat-reviewer` and the prompt linter), not the **source** (prompt template). The author writes the agent's prompt informed by the research; the oracle audits alignment. ## Purpose of these files -For each of the 14 abcd agents, `.md` answers four questions specific to that agent's job: +For each agent it covers, `.md` answers four questions specific to that agent's job: 1. **What is the closest prior art?** Existing prompts in public repos (Piebald, VoltAgent, EliFuzz, Cursor / Devin extracts), academic papers on the agent's task type, and any internal predecessors (e.g. the manual lifeboat for `flow-essence`). 2. **What are the agent-specific failure modes?** General failures from `01-general-best-practices.md` plus things that bite *this* agent specifically (e.g. context rot for `chat-distiller`, verbosity bias for `press-release-composer`, injection for `embark-scaffolder`). @@ -29,23 +29,19 @@ Use `_template.md` as the starting point. Every per-agent research file MUST inc Per-agent files are **immutable once their epic ships**. Subsequent additions go in a new section dated at the bottom of the file or, if structural, supersede with a new file (`-02.md`). Mirrors how the brief itself archives. -## Inventory (target: 14 files) - -| Agent | Pass | Highest agent-specific risk | File | -|---|---|---|---| -| `flow-essence` | A | Spec staleness in newest-first ordering | TBD | -| `decision-archaeologist` | A | ADR / git-log / CLAUDE.md cross-source synthesis | TBD | -| `review-collator` | A | Format drift across model-emitted reviews | TBD | -| `chat-distiller` | B | **Context rot** (highest in the suite) | [present](chat-distiller.md) | -| `principle-distiller` | C | Domain-grouping bias; missed-rationale gaps | TBD | -| `artefact-curator` | C | Keep/adapt/drop classification accuracy | TBD | -| `brief-composer` | C | Coherent-narrative-from-fragments | TBD | -| `press-release-composer` | C | **Verbosity bias** (highest in the suite) | TBD | -| `lifeboat-oracle` | C | LLM-judge biases; verdict-tag drift | TBD | -| `code-rescuer` | opt-in | Principle-extraction without code-level recommendation | TBD | -| `issue-scout` | opt-in | GitHub-search precision/recall | TBD | -| `embark-scaffolder` | embark | **Prompt injection** (highest in the suite); idempotent placement | [present](embark-scaffolder.md) | -| `launch-gatekeeper` | launch | PII regex completeness; false-negatives | TBD | -| `intent-fidelity-reviewer` | post-ship | LLM-judge bias against own intent author | TBD | - -Replace "TBD" with `present` as each agent's epic kicks off. +## Inventory + +The files here are measured against two rosters: the agent prompts that ship +under `agents/`, and the design roster the brief records as still to be built +([`05-internals/01-agents.md`](../../../brief/05-internals/01-agents.md), "The +design roster still to be built"). A research file for a design target is +research done ahead of its agent, not a baseline for a prompt that ships. + +| Research file | Agent | Standing against `agents/` | +|---|---|---| +| [`chat-distiller.md`](chat-distiller.md) | `chat-distiller` (Pass B) | Design target: no `agents/chat-distiller.md` ships, and the brief lists it on the roster still to be built. | +| [`embark-scaffolder.md`](embark-scaffolder.md) | `embark-scaffolder` (embark) | Design target: no `agents/embark-scaffolder.md` ships, and the brief lists it on the roster still to be built. | +| [`intent-fidelity-reviewer.md`](intent-fidelity-reviewer.md) | `intent-auditor` | Ships as [`agents/intent-auditor.md`](../../../../../agents/intent-auditor.md), renamed from `intent-fidelity-reviewer` by itd-123 (spc-28); the research file keeps the name it was written under. | + +Every other shipped agent under `agents/` has no per-agent research file here, +and neither does any other design target. diff --git a/.abcd/development/research/prompting/agents/intent-fidelity-reviewer.md b/.abcd/development/research/prompting/agents/intent-fidelity-reviewer.md index 1168c6ee2..88813d7ab 100644 --- a/.abcd/development/research/prompting/agents/intent-fidelity-reviewer.md +++ b/.abcd/development/research/prompting/agents/intent-fidelity-reviewer.md @@ -214,3 +214,15 @@ material § 0–6 rewrite that would change the findings' scope. - `../../../../agents/intent-fidelity-reviewer/fixtures/` — golden-test + injection-canary fixtures - `../../intents/disciplines/itd-1-acceptance-gates.md` — the acceptance-gate discipline Role 1's itd-1 pass enforces - `../../intents/disciplines/itd-37-modification-grammar.md` — the modification-grammar discipline Role 1's `MG004` pass enforces + +--- + +## 2026-09-29 — the agent's shipped name + +The agent this research informs ships as `agents/intent-auditor.md`, with its +fixtures under `agents/intent-auditor/fixtures/`: itd-123 (spc-28) renamed +`intent-fidelity-reviewer` to `intent-auditor` when `abcd intent review` became +`abcd intent audit`. The two `agents/intent-fidelity-reviewer` paths under +Related Documentation above name the agent by the name this file was written +under, and read as those two paths. The findings above are unchanged by the +rename. diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index d6e472ad9..06f2a81a4 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2582,3 +2582,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-28 — A parameter expansion is an unknown word, read for what its value can spell and not for what an earlier command carried into it, and the reading keeps its over-reads, recorded so they are not mistaken for defects (lane drainG3, autonomous run A, on iss-2609251824244354; this supersedes allow (4) of the first 2026-09-25 entry and ruling (c) of the third, which parked the plain-variable half). `$X`, `$1`, `$@`, `$*`, `$-` and a `${…}` read whole to its own `}` leave the unknown word's mark where the value goes, so `--$X` is every flag it could become and `$GIT` in command position is every program its known text allows; `$$`, `$!`, `$?` and `$#` print numbers and stay text, as an arithmetic expansion's output is a number. Allow (1) of the first 2026-09-25 entry extends to a word that is wholly a variable: it is one operand, so `git push origin "$branch"` stays allowed and `git push $X origin main` is not seen. A string handed to a shell is read with each variable-only word written back out (`sh -c "… $X …"` reads `$X` as that shell does), so a bare variable in a string raises no new warn, which keeps the gap `shellRawUninspectable` names: a value holding shell syntax is not read. The 4,315-input false-positive sweep (Makefile recipes, script lines and whole scripts, the repo-mined and adversarial corpora, and 81 everyday variable lines) moved from 4,000 allow / 47 block / 19 warn to 3,991 / 49 / 26 with 249 unparsable lines unchanged, after three readings of a carried value were left out because together they added 37 blocks on ordinary work: a variable handed to a shell or `source` as its script is not a stream (`bash "$script"`), and a variable standing as the program fires no entry that names only its program and an operand (pkill-by-pattern, killall-by-name) and is not a bare interpreter inside a string (`"$GO" build`, `$EDITOR notes.md`). Those, a pid list carried through a variable, and `eval "$X"` (which allows, as it did) are iss-2609281134544802, deferred past v0.11.0 for a ruling. Over-reads kept, each the variable twin of a ruled substitution over-read: a variable program name with a variable first operand can be `git clean` and warns (`exec "$BIN" "$@"`, `"$BASH" "$GATE"`; five lines of the sweep), `git -c core.quotePath=false "$@"` warns under git-clean, `git grep` whose pattern holds a variable and a `(` warns under the fail-safe as its substitution twin does, two printf continuation lines of a script read on their own (`"$n" "$n" "$spec" "$n"`) block as gh-repo-delete, `git -c "$KV" commit` blocks as a commit that may move core.hooksPath, and a stream piped into a shell whose script is a variable (`curl … | bash "$f"`) blocks, because a word wholly an expansion may be no word. The kill-by-search reading gains three feeds in the same lane (iss-2609270036253187): an unquoted here-document's substitutions are the standard input of the command that opened it, a substitution runs with the pipe into its own command as its input, and a shell string is handed the output of the substitutions in its command's words as its positional parameters or text, so every command of such a string is read as handed it (`sh -c 'kill 4242' _ "$(pgrep make)"` blocks), the over-block the 2026-09-27 entry accepts for a string xargs runs. - 2026-09-28 — v0.11.1 is published: autonomous run A approved the `release` environment at 22:44:40Z under ruling A2 and the releases ruling of 2026-09-25T08:04:52Z, after the merge queue, the verify job, the tag job and main's own CI on the tagged commit 2bc519f7 reported green (every check run succeeded apart from those skipped by design; the macOS leg of the push CI was the last to report). The release published at 22:47:00Z with four binaries, the plugin archive, checksums.txt and the site archive; the run verified the darwin-arm64 binary and the plugin archive against checksums.txt, `abcd --version` reports v0.11.1, the plugin archive's SHA-256 equals the digest the catalog pins and its address answers, and the build-provenance attestation verifies as signed by release.yml on main. Unlike v0.11.0, the site rendered and deployed inside the release run, so no redeploy was needed; abcdev.app shows v0.11.1. The version is v0.11.1 rather than the v0.12.0 the run had expected, because the cut derives the version from the records and nothing since v0.11.0 is breaking. - 2026-09-28 — Correction to the 2026-09-25 entry on the build's open-question check (lane implementer, autonomous run A, lane drainInt, on iss-2609260932374727). A settled LABEL (`resolved:`, `RESOLVED:`, `Deferred:`) is no longer read anywhere in the item: it counts opening a line of the item (its first line or a continuation line), after a closing bold (`**Which surface scaffolds it?** RESOLVED:`), or after a dash (`**Refusal breadth** — resolved:`, `**Relationship to itd-73** (derived versioning) — RESOLVED:`). The same word and colon mid-sentence are prose, so "Which id wins once the split is resolved: the old or the new?" is a question, where the entry's "anywhere in the item" read it as settled and let build start past it. The bold-span marker keeps its reach anywhere in the item. Every intent in the tree reads the same open-question count under the tightened rule as under the old one, so no record changes verdict. +- 2026-09-29 — Correction to the 2026-09-25 entry on the pre-commit guard's sources refresh (itd-76, iss-2609250834251447): that entry says the scaffolded copy's opt-in shape "answers none of the three questions the ruling holds", and half of that is not so. The template `abcd ahoy` scaffolds (`internal/core/ahoy/defaults/pre-commit`) takes a PROVISIONAL stance on two of the three questions: default or opt-in — opt-in, since nothing runs until the clone sets `abcd.sourcesBinary`; fail open or closed — open, since an unusable opt-in (not an absolute path to an executable regular file) prints one line and the commit proceeds. Only the first question, how a scaffolded hook finds abcd, stays unanswered in the template's own terms, which say it "deliberately does not take" that decision. Both provisional answers stand until the product thinker rules on iss-2609250834251447, and the ruling may replace either. The earlier entry stands as written, since the ledger is append-only (review2-sources finding 5; recorded by lane drainDrift2 of autonomous run A, iss-2609252055533837). diff --git a/.abcd/work/issues/open/iss-173-three-tier-layout-s-local-artefact-placement-check-matches-e.md b/.abcd/work/issues/open/iss-173-three-tier-layout-s-local-artefact-placement-check-matches-e.md deleted file mode 100644 index 055d72c91..000000000 --- a/.abcd/work/issues/open/iss-173-three-tier-layout-s-local-artefact-placement-check-matches-e.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-173" -slug: "three-tier-layout-s-local-artefact-placement-check-matches-e" -severity: "minor" -category: "security" -source: "agent-finding" -found_during: "iss-155 adversarial review round 2026-07-29" -found_at: "internal/core/audit/rule_layout.go" ---- - -three-tier-layout's local-artefact placement check matches exact names directly under a committed tier only — residual evasions flagged at review: a nested artefact (a NEXT.md one directory below a tier root), a NEXT.md at the .abcd/ root itself (one directory off the modelled incident), and lowercase name variants on case-sensitive filesystems all pass clean. Widening (depth, roots, case folding) must not start flagging legitimate tier content. \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-241-research-prompting-agents-holds-prompt-baselines-for-chat-di.md b/.abcd/work/issues/open/iss-241-research-prompting-agents-holds-prompt-baselines-for-chat-di.md deleted file mode 100644 index f855884f5..000000000 --- a/.abcd/work/issues/open/iss-241-research-prompting-agents-holds-prompt-baselines-for-chat-di.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-241" -slug: "research-prompting-agents-holds-prompt-baselines-for-chat-di" -severity: "minor" -category: "drift" -source: "agent-finding" -found_during: "intent-planning-prep" -found_at: ".abcd/development/research/prompting/agents" ---- - -research/prompting/agents/ holds prompt baselines for chat-distiller and embark-scaffolder, neither of which exists under agents/ any more — the baseline corpus has drifted from the shipped agent set. \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-2608291924452604-primaryworktreeroot-trusts-rev-parse-stdout-git-rev-parse-ec.md b/.abcd/work/issues/open/iss-2608291924452604-primaryworktreeroot-trusts-rev-parse-stdout-git-rev-parse-ec.md deleted file mode 100644 index 7e91582c9..000000000 --- a/.abcd/work/issues/open/iss-2608291924452604-primaryworktreeroot-trusts-rev-parse-stdout-git-rev-parse-ec.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-2608291924452604" -slug: "primaryworktreeroot-trusts-rev-parse-stdout-git-rev-parse-ec" -severity: "minor" -category: "bug" -source: "agent-finding" -found_during: "v0.6.9-security-review" -found_at: "internal/core/banlist/worktree.go" ---- - -PrimaryWorktreeRoot trusts rev-parse stdout: git rev-parse echoes an unrecognised option to stdout and exits 0, and --path-format only exists from git 2.31, so on an older git the --git-dir, --git-common-dir and --show-toplevel answers read as the flag text plus the path and every comparison silently fails closed to no primary store; validate each answer as a single absolute path or drop the flag diff --git a/.abcd/work/issues/open/iss-2609231010077584-itd-157-ac-3-narrowed-the-intent-promised-that-the-overlap.md b/.abcd/work/issues/open/iss-2609231010077584-itd-157-ac-3-narrowed-the-intent-promised-that-the-overlap.md index 12b019d82..36c93ac8b 100644 --- a/.abcd/work/issues/open/iss-2609231010077584-itd-157-ac-3-narrowed-the-intent-promised-that-the-overlap.md +++ b/.abcd/work/issues/open/iss-2609231010077584-itd-157-ac-3-narrowed-the-intent-promised-that-the-overlap.md @@ -9,6 +9,8 @@ found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: "internal/core/site/check.go" +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (rulings-owed section D, narrowing a shipped promise; run A 2026-09-29, lane drainDrift3): itd-157 ac-3 says the overlap gate flags a by-links overlap red, and only the test suite does. Should abcd lint site gain an eighth check that refuses a published layout with a non-zero overlap count (a new gate beside the seven adr-47 decision 3 arms), or should the criterion be amended to say the red result is the test suite plus the printed count, as spc-50's Approach reads it?" --- itd-157 ac-3 narrowed: the intent promised that the overlap gate 'flags the by-links overlap as a red result', and the delivery (PR #555, 64ac809c) widens the count to both arrangements and prints it (internal/surface/cli/site.go:201 'N overlapping bubbles across both arrangements'), but nothing outside the test suite goes red on a non-zero count — 'site build' exits 0 with the number in its summary, and 'site check' (internal/core/site/check.go) never reads Layout.Overlaps, so the make site-render gate passes a record whose by-links picture overlaps. The only red is TestBuildLayoutDoesNotOverlap over the test fixture and TestByLinksNeverOverlaps over synthetic corpora, neither of which sees a user's record. Either 'site check' should refuse on a non-zero published count or the intent's criterion should say the gate is the test suite diff --git a/.abcd/work/issues/open/iss-2609231526392449-guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid.md b/.abcd/work/issues/open/iss-2609231526392449-guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid.md deleted file mode 100644 index 7bb11f857..000000000 --- a/.abcd/work/issues/open/iss-2609231526392449-guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609231526392449" -slug: "guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid" -severity: "minor" -category: "security" -source: "user-observation" -found_during: "autonomous run 2026-09-23" -origin: researcher-authored -production_mode: hand-written ---- - -guard hook reads .abcd/guard.json from a REFUSED foreign-uid root when the workdir (or session cwd) is that root, while the refusal note says it was not read (internal/surface/cli/guard.go:430, internal/core/rules/root.go:255); it can only add hazards (Strictest), but the foreign file's why/successor text reaches the agent as the block message. Fixed on feat/ruled-security-forks (da3efea6/ebc6290d), not on main. diff --git a/.abcd/work/issues/open/iss-2609232155567377-itd-2609231013154443-promises-the-release-page-is-in-the.md b/.abcd/work/issues/open/iss-2609232155567377-itd-2609231013154443-promises-the-release-page-is-in-the.md index ab0ea9aed..337a873de 100644 --- a/.abcd/work/issues/open/iss-2609232155567377-itd-2609231013154443-promises-the-release-page-is-in-the.md +++ b/.abcd/work/issues/open/iss-2609232155567377-itd-2609231013154443-promises-the-release-page-is-in-the.md @@ -9,6 +9,8 @@ found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: "internal/core/release/page.go" +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (rulings-owed section D, narrowing a shipped promise; run A 2026-09-29, lane drainDrift3): itd-2609231013154443 promises the release page tells each headline intent in its own press release's words, quotes included, and the binary checks only the quotes a payload carries. Should the cut refuse a page that tells an intent whose press release carries a persona quote without one (a refusal the composer loop then rewrites, under Decision 9's unbounded retry), or should the press release stop promising quotes included, as the spec's 'Quotes are optional to the binary' risk already reads?" --- itd-2609231013154443 promises the release page is in the words each intent's own press release already uses, quotes included, and its scope names the persona quotes of the intents it tells, carried word for word; but the release-page ingest verifies only the quotes a payload carries. A headline may tell an intent whose press release carries a persona quote and carry none, and the cut writes the page. Found by the fidelity audit of ac-7 (receipt rcp-af55e181c483): agents/release-changelog-composer.md asks the composer for the quote of each told intent, and spc-2609231435545473 files the gap under Risks as quotes being optional to the binary because no criterion asks for them. Either the page bijection requires one quote per told intent whose source carries one, or the intent's press release stops promising quotes included. diff --git a/.abcd/work/issues/open/iss-2609240227236354-itd-6-reads-ready-but-four-prerequisites-are-unbuilt.md b/.abcd/work/issues/open/iss-2609240227236354-itd-6-reads-ready-but-four-prerequisites-are-unbuilt.md index f8dbf2f19..336041d52 100644 --- a/.abcd/work/issues/open/iss-2609240227236354-itd-6-reads-ready-but-four-prerequisites-are-unbuilt.md +++ b/.abcd/work/issues/open/iss-2609240227236354-itd-6-reads-ready-but-four-prerequisites-are-unbuilt.md @@ -9,6 +9,8 @@ found_during: "autonomous run A, planning briefs" origin: researcher-authored production_mode: hand-written found_at: ".abcd/development/specs/open/spc-2609211950427074-rp-mcp-only-integration.md" +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (run A 2026-09-29, lane drainDrift3): itd-6's builds_on and Implementation status now name the four missing prerequisites, but abcd intent ready reads no builds_on edge, so it still says READY. Should the readiness gate gain a check that refuses an intent whose builds_on names an intent not yet shipped (a ninth check, changing every planned intent's verdict that builds on planned work), or is the recorded prerequisite list enough and the gate stays as it is?" --- itd-6 passes its readiness gate (intent ready itd-6: READY, all seven checks ok) but cannot be built from today's tree: its spec spc-2609211950427074 stands on four things that do not exist. (1) The build loop's validator stage the adapter implements, itd-2609201916151817 (planned, spec spc-2609202134338445 open; there is no build verb). (2) The layered oracle.review resolver the model-tier and pacing intents share, itd-2609170822093401 and itd-2609201925079472 (both planned, specs spc-2609180535002478 and spc-2609202134341288 open). (3) The command-line runner the adapters chapter entry sits beside, itd-2609201916056194 (planned, spec spc-2609221533057881 open). (4) An MCP client, which the spec's Approach names as a new dependency subject to the new-dependency sign-off; go.mod has none. itd-6 declares builds_on [itd-2] only, so the gate has no edge to refuse on and READY reads as buildable to a run that picks it. diff --git a/.abcd/work/issues/open/iss-2609251618079479-after-adr-2609212115255771-retired-the-phase-the-milestone.md b/.abcd/work/issues/open/iss-2609251618079479-after-adr-2609212115255771-retired-the-phase-the-milestone.md index 55a7ef751..92c057afa 100644 --- a/.abcd/work/issues/open/iss-2609251618079479-after-adr-2609212115255771-retired-the-phase-the-milestone.md +++ b/.abcd/work/issues/open/iss-2609251618079479-after-adr-2609212115255771-retired-the-phase-the-milestone.md @@ -9,6 +9,8 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: ".abcd/development/brief/01-product/04-scope.md" +deferred_after: "v0.11.1" +deferral_reason: "no ruling owed; carried past v0.11.1 by lane drainDrift3 (run A 2026-09-29) on its size: adr-2609212115255771 settles the model (sequencing is builds_on and blocked_by plus the drafts, planned and shipped shelves; the checkpoint is the derived release), but at 8322cdf65 the brief still names the phase at about 250 sites in 60 files, and each needs a reading to tell the retired sequencing unit from a legitimate use (the retired-term glossary entry, the phase-audit receipt, a process phase). It wants a docs lane of its own, taking 01-product/04-scope.md and the release, version, loop, plan and spec glossary entries first." --- After adr-2609212115255771 retired the phase, the milestone and the word roadmap, the brief outside the mental-model chapter still describes the phase as the live sequencing unit: 59 files under .abcd/development/brief mention it, among them 01-product/04-scope.md (bounded by the planned phases), and the glossary entries release, version, loop, plan and spec, whose prose says a phase sequences the work and a release falls out of completing one. itd-2609211913453478 rewrote only the mental-model chapter and repointed each entry's not_to_be_confused_with; no record carries the rest of the sweep. diff --git a/.abcd/work/issues/open/iss-2609251645376019-abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent.md b/.abcd/work/issues/open/iss-2609251645376019-abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent.md deleted file mode 100644 index 20dda8e40..000000000 --- a/.abcd/work/issues/open/iss-2609251645376019-abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609251645376019" -slug: "abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent" -severity: "minor" -category: "ux" -source: "user-observation" -found_during: "autonomous run A resumed 2026-09-25" -origin: researcher-authored -production_mode: hand-written ---- - -abcd help --agent refuses with cobra's bare 'unknown flag: --agent' (exit 2), while abcd --agent refuses naming the spelling that works; the help-subcommand path does not name --help --agent (review-helpgroups 3). diff --git a/.abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md b/.abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md deleted file mode 100644 index 3f9ad03ce..000000000 --- a/.abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609252055533837" -slug: "the-decisions-entry-for-the-sources-refresh-says-the-opt-in" -severity: "minor" -category: "documentation" -source: "user-observation" -found_during: "autonomous run A resumed 2026-09-25" -origin: researcher-authored -production_mode: hand-written -found_at: ".abcd/work/DECISIONS.md" ---- - -The DECISIONS entry for the sources refresh says the opt-in shape decides none of iss-2609250834251447's questions, but the template does take a provisional stance for the pre-commit refresh (opt-in; fail open on an unusable opt-in); the entry should call that half provisional (review2-sources 5). diff --git a/.abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md b/.abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md deleted file mode 100644 index 7a95ce98a..000000000 --- a/.abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609260933592838" -slug: "archive-tree-reads-attributes-from-the-index" -severity: "minor" -category: "bug" -source: "review-followup" -found_during: "autonomous run A resumed 2026-09-25: review2-launchkind side note" -origin: researcher-authored -production_mode: hand-written -found_at: "internal/gitutil/archive.go" ---- - -gitutil.ArchiveTree reads export attributes with `git check-attr --cached` (the index) while `git archive` reads them from the archived tree, so the two disagree when a .gitattributes change is staged but not committed: the launch listing can include or omit a path the released archive does the opposite with. `check-attr --source=` (git 2.40 or later) reads the same tree git archive does. diff --git a/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md b/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md index 34e3cda4c..ceb4ac86e 100644 --- a/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md +++ b/.abcd/work/issues/open/iss-2609261056373310-scribe-assemble-reads-the-ledger-as-it-stands-in-the-working.md @@ -9,6 +9,8 @@ found_during: "autonomous run A resumed 2026-09-25: review-scribe" origin: researcher-authored production_mode: hand-written found_at: "internal/core/scribe/assemble.go" +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (run A 2026-09-29, lane drainDrift3; the 2026-09-26 DECISIONS entry leaves it undecided): scribe assemble reads the ledger as it stands in the working tree, uncommitted records included, while itd-2609020625402599's scope condition cond-2609020626046719 says committed ledger content. Should the condition be amended to the working tree, or should the assembler read committed content only and refuse or report uncommitted records?" --- scribe assemble reads the ledger as it stands in the working tree, uncommitted records included, while itd-2609020625402599's scope condition (cond-2609020626046719) says the context is assembled from committed ledger content; which one moves is a ruling owed: amend the condition to the working tree, or have the assembler read committed content (and refuse or report uncommitted records) diff --git a/.abcd/work/issues/open/iss-2609261447395216-ahoy-install-asks-private-repo-trufflehog-present-confirm.md b/.abcd/work/issues/open/iss-2609261447395216-ahoy-install-asks-private-repo-trufflehog-present-confirm.md index f938f816e..8005684b3 100644 --- a/.abcd/work/issues/open/iss-2609261447395216-ahoy-install-asks-private-repo-trufflehog-present-confirm.md +++ b/.abcd/work/issues/open/iss-2609261447395216-ahoy-install-asks-private-repo-trufflehog-present-confirm.md @@ -9,6 +9,8 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/apply.go" +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (run A 2026-09-29, lane drainDrift3): ahoy install asks to confirm deep secret scanning when a private repository has trufflehog on PATH and persists scan.deep, but nothing reads scan.deep and nothing runs trufflehog. Should abcd wire a trufflehog adapter behind the scanner seam so the answer takes effect, or stop asking and say the key is inert where it is already set?" --- ahoy install asks 'Private repo + trufflehog present — confirm deep secret scanning' and persists scan.deep in .abcd/config.json, but no scanner reads scan.deep and nothing runs trufflehog, so the answer changes nothing: a person told they enabled deep secret scanning has not. Either wire a trufflehog adapter behind the scanner seam or stop asking (and say the value is inert where it is already set). diff --git a/.abcd/work/issues/open/iss-2609261536147903-planned-intents-and-disciplines-still-cite-specs-of-the.md b/.abcd/work/issues/open/iss-2609261536147903-planned-intents-and-disciplines-still-cite-specs-of-the.md deleted file mode 100644 index 42324019b..000000000 --- a/.abcd/work/issues/open/iss-2609261536147903-planned-intents-and-disciplines-still-cite-specs-of-the.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -schema_version: 1 -id: "iss-2609261536147903" -slug: "planned-intents-and-disciplines-still-cite-specs-of-the" -severity: "minor" -category: "drift" -source: "agent-finding" -found_during: "autonomous run A resumed 2026-09-25" -origin: researcher-authored -production_mode: hand-written -found_at: ".abcd/development/intents/planned" ---- - -Planned intents and disciplines still cite specs of the retired predecessor store unqualified, and such an id at or below spc-70 resolves to a live spec on another subject (spc-12, for one, is live as the disembark grounding spec): itd-48, itd-50 and itd-53 name spc-12, spc-28, spc-29, spc-31, spc-33, spc-43 and spc-52 for the fidelity reviewer, lifecycle hook and cleanup work (which of these are predecessor ids is itself the reading this record owes); itd-24 names spc-66 as the phase-audit receipt; itd-1 and itd-37 name spc-12 as the manual reviewer. The specs charter (Two spc-N Namespaces) rules that every predecessor citation carries the '(predecessor store)' qualifier; iss-239 applied it to the draft corpus only. Each site needs a reading to tell a predecessor citation from a live one, which is why the sweep was not folded into iss-239. diff --git a/.abcd/work/issues/open/iss-2609262107472569-itd-53-fidelity-audit-the-press-release-promises-a-standing.md b/.abcd/work/issues/open/iss-2609262107472569-itd-53-fidelity-audit-the-press-release-promises-a-standing.md index c86b36c08..d8ad748e2 100644 --- a/.abcd/work/issues/open/iss-2609262107472569-itd-53-fidelity-audit-the-press-release-promises-a-standing.md +++ b/.abcd/work/issues/open/iss-2609262107472569-itd-53-fidelity-audit-the-press-release-promises-a-standing.md @@ -8,6 +8,8 @@ source: "review-followup" found_during: "autonomous run A resumed 2026-09-25: fidelity audit itd-53" origin: researcher-authored production_mode: hand-written +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (rulings-owed section D, narrowing a shipped promise; run A 2026-09-29, lane drainDrift3): itd-53's press release promises a standing list of shipped intents whose latest fidelity review is missing or not-met, and the listing it names (bare abcd intent audit, itd-2609150819445595) reads the review marker only, so a NOT_MET verdict reads INGESTED and leaves the list. Should the listing gain a not-met heading read from the latest verdict's rollup, or should itd-53's promise be amended to missing reviews only, with not-met left to the capture each verdict's page instructs?" --- itd-53 fidelity audit: the press release promises a standing list of shipped intents whose latest fidelity review is missing OR not-met, and decision 2 names the owed listing as that report; the delivered listing (bare intent audit / itd-2609150819445595) reads the review marker state only, so an intent whose ingested verdict was NOT_MET reads INGESTED and leaves the list, and the unmet half survives only as the page-instructed capture per verdict (receipt rcp-d2372b1cb47f) diff --git a/.abcd/work/issues/open/iss-2609290000171068-itd-2609212130146198-s-press-release-promises-that-an-agent.md b/.abcd/work/issues/open/iss-2609290000171068-itd-2609212130146198-s-press-release-promises-that-an-agent.md index 679650771..b6a704a9d 100644 --- a/.abcd/work/issues/open/iss-2609290000171068-itd-2609212130146198-s-press-release-promises-that-an-agent.md +++ b/.abcd/work/issues/open/iss-2609290000171068-itd-2609212130146198-s-press-release-promises-that-an-agent.md @@ -9,6 +9,8 @@ found_during: "autonomous run A resumed 2026-09-25: fidelity audit itd-260921213 found_at: "internal/surface/cli/guard_question.go" origin: researcher-authored production_mode: hand-written +deferred_after: "v0.11.1" +deferral_reason: "ruling owed to the product thinker (rulings-owed section D, narrowing a shipped promise; run A 2026-09-29, lane drainDrift3): itd-2609212130146198's press release says an agent cannot ask the human anything until it has said whom it is asking, and the guard covers only the host's question tool, as the intent's scope and Decision 1 confine it. Should the press release's headline be narrowed to the question tool (its persona quote is already published on the v0.11.1 release page and would be left as quoted), or should a stop-time gate be built that also catches a question asked in prose at the end of a turn?" --- itd-2609212130146198's press release promises that an agent cannot ask the human anything until it has said whom it is asking, but the delivered guard (criterion 2) covers only the host's question tool: a stop that asks in prose at the end of a turn is not gated, so the badge can still read abcd-managed while an answer is owed, which is the Mechanism's own falsifier. The intent's scope confines the guard to the question tool, so the press release overclaims; either the promise narrows to the tool or a stop-time gate closes the prose path. Found by the fidelity audit at 52c2236a5 (internal/surface/cli/guard_question.go questionTools). diff --git a/.abcd/work/issues/open/iss-2609290055092630-fsutil-redactroot-and-the-scanner-s-home-sweep-still.md b/.abcd/work/issues/open/iss-2609290055092630-fsutil-redactroot-and-the-scanner-s-home-sweep-still.md new file mode 100644 index 000000000..702e85981 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609290055092630-fsutil-redactroot-and-the-scanner-s-home-sweep-still.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609290055092630" +slug: "fsutil-redactroot-and-the-scanner-s-home-sweep-still" +severity: "minor" +category: "inconsistency" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/fsutil/paths.go" +--- + +fsutil.RedactRoot and the scanner's home sweep still disagree on the LEADING boundary for a home of two or more segments under a longer root: SweepCallerHome sweeps '/Volumes/T7/srv/qzhome/x' to '/Volumes/T7~/x' (iss-324: a longer root does not make the name someone else's), while RedactRoot, and so RedactHome, the CLI error scrub and the install receipt, leave it whole because the byte before the root is a path-segment byte (iss-2609230641546141 chose that to stop '/private~/wt/a' mangles). The identity survives in CLI output wherever a message names a home under a backup volume or mount. Closing it needs a choice between the two rules for the '~' polarity (the '.' repo polarity has no counterpart); found while unifying the trailing half under iss-2608292037564347. + +## Evidence, 2026-09-29 + +The review of the trailing-boundary unification (iss-2608292037564347) found two further places where the redactors read a path differently. Both belong to the question this record already asks: should RedactRoot and the scanner's home sweep agree. + +1. The '.' repo polarity renders a suffixed repo root as a dot pair. Since 5d8bdc084 '.' ends a name, so a message naming the repo root with a trailing dot or a `.git` suffix is redacted with the replacement's own '.' left in front of the suffix: `fsutil.RedactRoot("see /Users/alice/proj.", "/Users/alice/proj", ".")` returns `see ..`, and `fsutil.RedactRoot("clone /Users/alice/proj.git now", "/Users/alice/proj", ".")` returns `clone ..git now`. Both read like a parent-directory reference. The redaction is the safe direction: the same probe at 5d8bdc084's parent returns both strings whole, leaking the full path. A fix is a choice of rendering for the '.' polarity, not of boundary. +2. On a case-folding host (macOS/APFS) the two redactors disagree on a case-variant home: `fsutil.RedactRoot("open /USERS/ALICE/a", "/Users/alice", "~")` returns `open ~/a`, while `scanner.SweepCallerHome("open /USERS/ALICE/a", "/Users/alice")` returns the string whole. RedactRoot folds case by design (iss-2608270908341622); the sweep does not, and whether the store's backstop covers the variant is unverified. This predates 5d8bdc084. + +Probes: an external `fsutil_test` probe on a `git archive` copy of fix/drain-drift-2 at b8d69535c, and the same probe on a copy of 5d8bdc084's parent, both on darwin. diff --git a/.abcd/work/issues/open/iss-2609290405451613-the-release-cut-accepts-a-third-party-s-home-path-and-any.md b/.abcd/work/issues/open/iss-2609290405451613-the-release-cut-accepts-a-third-party-s-home-path-and-any.md new file mode 100644 index 000000000..0fd5964ac --- /dev/null +++ b/.abcd/work/issues/open/iss-2609290405451613-the-release-cut-accepts-a-third-party-s-home-path-and-any.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609290405451613" +slug: "the-release-cut-accepts-a-third-party-s-home-path-and-any" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/release/ingest.go" +--- + +The release cut accepts a third party's home path, and any other identity finding the scanner grades warn, in public release text. After the hard_fail refusal of iss-2609290405381338, a changelog entries[].text or press_release headline carrying another account's home path (home_path_other) or a GitHub username (github_username) is still written to CHANGELOG.md and RELEASE.md as it stands, because the cut refuses at the bar the launch scan applies to the same files, hard_fail only. Refusing these too is a policy choice, not a mechanical fix: the store-before-commit redactors (history, memory, ideate) redact every identity kind and refuse any that survives, whatever its severity, but github_username fires on the repository owner's name standing as a bare word (a URL and the owner/repo slug are exempt), which a release note may legitimately carry, and the scanner grades that kind review-only, warn, for that reason. Ruling owed to the product thinker: whether public release text is held to the launch bar (hard_fail only, as now), to the store-before-commit bar (every identity kind, accepting refusals of the owner's bare name), or to a middle bar (third-party home paths refused, usernames allowed). Found by the security review of lane drainEcho2 and split from iss-2609290405381338 by lane drainEcho3. Detector, once ruled: a payload carrying a third-party home path in a changelog line is refused or accepted as the ruling says, and the refusal does not carry the path. diff --git a/.abcd/work/issues/open/iss-2609290448510918-predecessor-qualifier-sweep-past-the-six-named-intents.md b/.abcd/work/issues/open/iss-2609290448510918-predecessor-qualifier-sweep-past-the-six-named-intents.md new file mode 100644 index 000000000..129d34307 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609290448510918-predecessor-qualifier-sweep-past-the-six-named-intents.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2609290448510918" +slug: "predecessor-qualifier-sweep-past-the-six-named-intents" +severity: "minor" +category: "drift" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/intents" +deferred_after: "v0.11.1" +deferral_reason: "no ruling owed; carried past v0.11.1 by lane drainDrift3 (run A 2026-09-29) on its size: the 192 sites each need a reading against the live spec their id collides with, which the lane's time box did not hold after qualifying the six intents iss-2609261536147903 named." +--- + +The predecessor-store qualifier sweep reaches past the six intents iss-2609261536147903 named: outside drafts/ and those six, 192 citation sites across about forty intents name a spc-N at or below spc-70 that is not the citing intent's own spec, with no '(predecessor store)' on the line. Some are live cross-references (the cold-reading family's intents cite each other's live specs), and some are the predecessor store's (itd-4, itd-6, itd-29, itd-47 and itd-49 describe pre-rebuild work in its terms), so each site needs the same reading against the live spec it collides with; the specs charter's Two spc-N Namespaces section is the rule. Found while sweeping the pattern of iss-2609261536147903 in lane drainDrift3; a census script over intents/{planned,shipped,disciplines} reproduces the count. diff --git a/.abcd/work/issues/open/iss-94-the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo.md b/.abcd/work/issues/open/iss-94-the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo.md deleted file mode 100644 index f592fbdfd..000000000 --- a/.abcd/work/issues/open/iss-94-the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-94" -slug: "the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo" -severity: "minor" -category: "drift" -source: "agent-finding" -found_during: "itd-88-m0" -found_at: ".abcd/development/intents" ---- - -The intent corpus still specifies the pre-adr-35 lifeboat model: itd-2, itd-8, itd-9, itd-10, itd-13, itd-15, itd-19, itd-22 and itd-24 variously use the retired 'disembark to home' signature, the in-tree .abcd/lifeboat/ home, or the in-tree .abcd/development/voyage/ path (itd-9's acceptance writes voyage provenance in-tree — the exact path that would fail abcd's own privacy-hygiene audit rule). The brief, glossary and roadmap were reconciled to adr-35; the intents were deliberately NOT rewritten, because an intent is a proposal with its own lifecycle and silently rewriting nine of them inside an unrelated change is worse than tracking the drift. Each reconciles when it is next planned. \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-173-three-tier-layout-s-local-artefact-placement-check-matches-e.md b/.abcd/work/issues/resolved/iss-173-three-tier-layout-s-local-artefact-placement-check-matches-e.md new file mode 100644 index 000000000..fe952c8f8 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-173-three-tier-layout-s-local-artefact-placement-check-matches-e.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-173" +slug: "three-tier-layout-s-local-artefact-placement-check-matches-e" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "iss-155 adversarial review round 2026-07-29" +found_at: "internal/core/audit/rule_layout.go" +resolution: "Fixed: three-tier-layout lists directories instead of probing fixed paths, matches NEXT.md, scratch/ and logs/ in any case, reports them at the .abcd/ root as well as directly under a committed tier, and reports NEXT.md at any depth in a committed tier. scratch/ and logs/ are not flagged below a tier's top level, since a durable record may legitimately nest a study's own logs/; that residual is stated." +impact: fix +resolved_by: + commit: "f2ec7c8f0" +--- + +three-tier-layout's local-artefact placement check matches exact names directly under a committed tier only — residual evasions flagged at review: a nested artefact (a NEXT.md one directory below a tier root), a NEXT.md at the .abcd/ root itself (one directory off the modelled incident), and lowercase name variants on case-sensitive filesystems all pass clean. Widening (depth, roots, case folding) must not start flagging legitimate tier content. + +## Grounds + +- pursued: a nested handover, a local artefact at the .abcd root, and another-case spelling are each flagged, and legitimate nested tier content is not; a fixture of any of those shapes that lints clean, or a study's nested logs/ that is flagged, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-184-guard-heredoc-arithmetic-shift-bypass.md b/.abcd/work/issues/resolved/iss-184-guard-heredoc-arithmetic-shift-bypass.md index 257b5215c..55522981f 100644 --- a/.abcd/work/issues/resolved/iss-184-guard-heredoc-arithmetic-shift-bypass.md +++ b/.abcd/work/issues/resolved/iss-184-guard-heredoc-arithmetic-shift-bypass.md @@ -11,4 +11,17 @@ resolution: "Fixed at root cause, in two parts after a pre-PR security review ca impact: fix --- -guard tokenizer heredoc misparse: an unquoted arithmetic left-shift with an identifier operand (e.g. `$((1<.', '-old' and '_x'. The leading half still differs by design for a multi-segment home under a longer root; captured separately." +impact: fix +resolved_by: + commit: "5d8bdc084" --- v0.6.9 combined ruthless review: three sites carry the same path-boundary predicate in different polarities and now disagree on a trailing suffix. fsutil.isPathBoundary (internal/fsutil/paths.go) and scanner.isPathSegmentByte (internal/adapter/scanner/identity.go) are the same byte class inverted, and scanner.nameContinues (internal/adapter/scanner/residual.go) is the home-path anchor's rule — alphanumeric only. So fsutil.RedactRoot / RedactHome leave 'cannot access /Users/.' untouched (the '.' is a segment byte to them) while SweepCallerHome sweeps it, and the CLI error scrub and the install receipt disagree with the store redactors about the same sentence. Proposal: one home for the predicate — fsutil exports the boundary rule, scanner imports it, and the alnum-only trailing rule is the shared definition — taken in its own pass because fsutil's callers (error scrub, receipts, RedactRoot) are a different set from the scanner's. + +## Grounds + +- pursued: TestHomeRedactorsAgreeOnTheTrailingBoundary holds RedactRoot and SweepCallerHome to one answer for every printable byte after the home (RED on '.', '-', '_' before the change); a byte where they disagree, or a redaction of a longer alphanumeric name, would show it wrong diff --git a/.abcd/work/issues/open/iss-2608301251394412-openstag-restates-htmltagre-s-rule-in-hand-written-code-givi.md b/.abcd/work/issues/resolved/iss-2608301251394412-openstag-restates-htmltagre-s-rule-in-hand-written-code-givi.md similarity index 56% rename from .abcd/work/issues/open/iss-2608301251394412-openstag-restates-htmltagre-s-rule-in-hand-written-code-givi.md rename to .abcd/work/issues/resolved/iss-2608301251394412-openstag-restates-htmltagre-s-rule-in-hand-written-code-givi.md index 91f4f4631..bc0bd7245 100644 --- a/.abcd/work/issues/open/iss-2608301251394412-openstag-restates-htmltagre-s-rule-in-hand-written-code-givi.md +++ b/.abcd/work/issues/resolved/iss-2608301251394412-openstag-restates-htmltagre-s-rule-in-hand-written-code-givi.md @@ -7,6 +7,10 @@ category: "tech-debt" source: "user-observation" found_during: "itd-183-round-9-ruthless" found_at: "internal/core/reading/project.go" +resolution: "opensTag and htmlTagRe build from one constant, htmlTagOpen, in internal/core/reading/project.go: htmlTagRe appends the rest of the tag and opensTag asks htmlTagOpenRe, the anchored opening half. The hand-written copy disagreed with the pattern on autolinks, which it opened on; TestOpensTagIsHTMLTagResRule now holds the two to agreement." +impact: internal +resolved_by: + commit: "c8732bf07" --- opensTag restates htmlTagRe's rule in hand-written code giving one file two definitions of what opens a tag @@ -19,3 +23,7 @@ of "what opens a tag" in one file. Repo law (one-canonical-primitive): flag for consolidation on the second copy, never let a third appear. Flagged here so the floor intent inherits it. + +## Grounds + +- pursued: we expect the attribute walk to open exactly where htmlTagRe matches on any input the pattern reads to its closing bracket; an input where opensTag and htmlTagRe disagree would show it wrong diff --git a/.abcd/work/issues/open/iss-2608301301041887-the-lock-assertion-in-the-grounds-test-is-wall-clock-so-a-lo.md b/.abcd/work/issues/resolved/iss-2608301301041887-the-lock-assertion-in-the-grounds-test-is-wall-clock-so-a-lo.md similarity index 59% rename from .abcd/work/issues/open/iss-2608301301041887-the-lock-assertion-in-the-grounds-test-is-wall-clock-so-a-lo.md rename to .abcd/work/issues/resolved/iss-2608301301041887-the-lock-assertion-in-the-grounds-test-is-wall-clock-so-a-lo.md index 3507c93c5..d8788b23b 100644 --- a/.abcd/work/issues/open/iss-2608301301041887-the-lock-assertion-in-the-grounds-test-is-wall-clock-so-a-lo.md +++ b/.abcd/work/issues/resolved/iss-2608301301041887-the-lock-assertion-in-the-grounds-test-is-wall-clock-so-a-lo.md @@ -7,6 +7,10 @@ category: "tech-debt" source: "user-observation" found_during: "itd-179-round-3-ruthless" found_at: "internal/core/intent/grounds_test.go" +resolution: "Both mint-lock proofs in internal/core/intent assert an observed ordering instead of a wait: withIntentMintLockWithin calls the onIntentMintLockBusy test seam when an attempt finds the lock held, the holder releases only on that event, and a flag raised inside its critical section tells a writer that waited from one that took no lock. TestRecordGroundsHoldsTheMintLock and its sibling TestStampPlannedHoldsTheMintLock (claims_fence_test.go), which carried the same wall-clock shape, both fail a scratch mutant that sleeps 200ms and skips the lock, which the old wait bar passed." +impact: internal +resolved_by: + commit: "749fcf915" --- the lock assertion in the grounds test is wall-clock so a loaded runner could pass it with no lock held @@ -26,3 +30,7 @@ deterministic one. Worth fixing when the file is next touched, because a timing assertion that can pass for the wrong reason is the seed of the mutation-vacuous class this workstream has now found four times. + +## Grounds + +- pursued: we expect both lock tests to fail any writer that skips the mint lock, however slow; a no-lock mutant that passes either test would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2608301808193750-the-blank-grounds-enumeration-claims-the-trailing-comment-is.md b/.abcd/work/issues/resolved/iss-2608301808193750-the-blank-grounds-enumeration-claims-the-trailing-comment-is.md index 64a2473ab..1db50e305 100644 --- a/.abcd/work/issues/resolved/iss-2608301808193750-the-blank-grounds-enumeration-claims-the-trailing-comment-is.md +++ b/.abcd/work/issues/resolved/iss-2608301808193750-the-blank-grounds-enumeration-claims-the-trailing-comment-is.md @@ -48,3 +48,14 @@ Remedy for THIS record is the reword: scope both sentences to the spellings the predicate actually tests. The gate is strictly better than it was either way; what may not stand is a sentence telling a reader the hole is closed when it is not. The widening is `iss-2608301808198621` and is deliberately separate. + +## Correction, 2026-09-29 + +The resolution was true when it was written, and its "deliberately NOT +widened" is not true at this tip. `3eb4b549` ("fix: gate the cold-reading evals +and close the eval, ledger and ingest hygiene issues") took the widening this +record left to iss-2608301808198621: absence is decided by the class of YAML +node a value spells (`internal/core/frontmatter.IsEmptyValue`), not by a list of +spellings. The same commit removed `TestIsAbsentValueIsASpellingTestNotANullTest`, +which the resolution names; `TestAbsenceIsDecidedByClassNotBySpelling` in +`internal/core/lint/schema_absence_test.go` pins the class rule. diff --git a/.abcd/work/issues/open/iss-2608301908288212-four-nits-from-the-itd-179-fix-delta-review-including-a-body.md b/.abcd/work/issues/resolved/iss-2608301908288212-four-nits-from-the-itd-179-fix-delta-review-including-a-body.md similarity index 81% rename from .abcd/work/issues/open/iss-2608301908288212-four-nits-from-the-itd-179-fix-delta-review-including-a-body.md rename to .abcd/work/issues/resolved/iss-2608301908288212-four-nits-from-the-itd-179-fix-delta-review-including-a-body.md index 7a53727fc..79e93ebda 100644 --- a/.abcd/work/issues/open/iss-2608301908288212-four-nits-from-the-itd-179-fix-delta-review-including-a-body.md +++ b/.abcd/work/issues/resolved/iss-2608301908288212-four-nits-from-the-itd-179-fix-delta-review-including-a-body.md @@ -7,6 +7,10 @@ category: "tech-debt" source: "user-observation" found_during: "itd-179-fix-delta-ruthless" found_at: "internal/core/grounds/record.go" +resolution: "All seven nits fixed: rendered-body line numbers, corrected Body/ParseGrounds doc claims, depth-free two-headings refusal naming each heading's line, one blank line above a section in a frontmatter-only record, the writer's opener on line 0 as every reader requires, and clipped quotes." +impact: internal +resolved_by: + commit: "0118ba198a36cf41e3794b45986d1084f184aba2" --- four nits from the itd-179 fix delta review including a body line offset that is one ahead of the rendered body @@ -47,3 +51,6 @@ reviews converged on it. 256 KiB, so the refusal's `%q` can quote an arbitrarily long body line into an error string. Cosmetic. +## Grounds + +- pursued: every append refusal now names a line an operator finds in the body the reader renders and quotes it bounded; a refusal naming the line below the opener, or the writer accepting a leading-blank record, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2608311632382737-the-pre-push-gate-is-blind-to-both-eval-lanes-so-the-read-bl.md b/.abcd/work/issues/resolved/iss-2608311632382737-the-pre-push-gate-is-blind-to-both-eval-lanes-so-the-read-bl.md index d892383c8..25d872676 100644 --- a/.abcd/work/issues/resolved/iss-2608311632382737-the-pre-push-gate-is-blind-to-both-eval-lanes-so-the-read-bl.md +++ b/.abcd/work/issues/resolved/iss-2608311632382737-the-pre-push-gate-is-blind-to-both-eval-lanes-so-the-read-bl.md @@ -18,3 +18,11 @@ The pre-push gate is blind to both eval lanes, so the read-block eval is guarded ## Grounds - pursued: a defect in the read-block or amnesia eval now fails locally before it reaches CI; it would be shown wrong by a preflight run that is green while make evals-cold-reading or make smoke is red. + +## Correction, 2026-09-29 + +The test the resolution names, `TestPreflightRunsBothEvalLanes`, is +`TestPreflightRunsEveryTaggedEvalLane` in `internal/core/lint/preflightgates_test.go` +since `3eb4b549`, which derives the eval lanes from the Makefile rather than +naming two. The rename changes nothing the resolution claims: the pre-push gate +still runs every tagged eval lane. diff --git a/.abcd/work/issues/resolved/iss-2608311632439831-spc-64-s-positions-exercised-paragraph-is-stale-against-the.md b/.abcd/work/issues/resolved/iss-2608311632439831-spc-64-s-positions-exercised-paragraph-is-stale-against-the.md index c9c252359..ada6a0488 100644 --- a/.abcd/work/issues/resolved/iss-2608311632439831-spc-64-s-positions-exercised-paragraph-is-stale-against-the.md +++ b/.abcd/work/issues/resolved/iss-2608311632439831-spc-64-s-positions-exercised-paragraph-is-stale-against-the.md @@ -20,3 +20,14 @@ spc-64's Positions exercised paragraph is stale against the eval it specifies. I ## Grounds - pursued: a reader sizing up the eval's coverage from spc-64 must reach the same answer the eval's own position tables give; falsified if the spec's paragraph and the position sets in evals/coldreading_fixture_test.go disagree again, or if a reader concludes from it that some position carries less than the full assertion set for a reason other than refusing to assemble. + +## Correction, 2026-09-29 + +The resolution was true when it was written, and the refusal it describes no +longer exists. `3b62c967` ("feat: a comparative reading receives the widening +run's items as its candidate set, and characterises them before anyone admits +one") made the comparative position assemble from the widening run's items, and +removed `TestComparativeRefusesToAssemble`, which the resolution names. +`TestComparativeChannelCarriesCandidatesAndNothingElse` in +`evals/coldreading_test.go` and the candidate tests in +`internal/core/reading/candidates_test.go` pin what replaced the refusal. diff --git a/.abcd/work/issues/open/iss-2609012037125129-sibling-of-ghsa-fh9j-8xmg-m33f-cwe-59-cwe-400-found-on-the-s.md b/.abcd/work/issues/resolved/iss-2609012037125129-sibling-of-ghsa-fh9j-8xmg-m33f-cwe-59-cwe-400-found-on-the-s.md similarity index 60% rename from .abcd/work/issues/open/iss-2609012037125129-sibling-of-ghsa-fh9j-8xmg-m33f-cwe-59-cwe-400-found-on-the-s.md rename to .abcd/work/issues/resolved/iss-2609012037125129-sibling-of-ghsa-fh9j-8xmg-m33f-cwe-59-cwe-400-found-on-the-s.md index 93195d01d..2f7682d92 100644 --- a/.abcd/work/issues/open/iss-2609012037125129-sibling-of-ghsa-fh9j-8xmg-m33f-cwe-59-cwe-400-found-on-the-s.md +++ b/.abcd/work/issues/resolved/iss-2609012037125129-sibling-of-ghsa-fh9j-8xmg-m33f-cwe-59-cwe-400-found-on-the-s.md @@ -9,6 +9,15 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/glossary/index.go" +resolution: "Resolved on its merits: fixed at base by 746a5d2c (shipped in v0.8.0). ScanInRoot reads every term file through fsutil.ReadGuardedInRoot with the term-family cap maxTermBytes (index.go:188), so a FIFO, a symlinked leaf, an out-of-root ancestor link and an oversized file are all refused; the cap the record left open was chosen there. The record's requested test in the term store was missing and is added in ef2b4ae4a (guarded_read_test.go: FIFO within a deadline, symlinked term, one byte past the cap), each killed on a scratch copy with the pre-advisory os.ReadFile restored." +impact: fix +shipped_in: v0.8.0 +resolved_by: + commit: "746a5d2c2" --- Sibling of GHSA-fh9j-8xmg-m33f (CWE-59, CWE-400), found on the sweep and not fixed there: glossary readTerm (internal/core/glossary/index.go) loads every term file the index walk finds through a bare os.ReadFile — no O_NOFOLLOW, no O_NONBLOCK, no regular-file check, no byte cap. The glossary store is committed and travels with a clone, so a committed FIFO at a term name hangs every verb that builds the index, and a committed symlink reads an out-of-tree file as a term. The fix is the same one-line routing through fsutil.ReadGuarded that the issue and reading families now use, plus a byte cap chosen for the term family (the glossary has no cap constant of its own yet, which is the one decision that kept this out of the advisory fix) and a test with a FIFO and a symlinked leaf in the term store. Already-captured siblings are iss-2608301203521317 (lint scanRecordStores) and iss-2608211914592726 (the residual lint sweep). + +## Grounds + +- pursued: a glossary scan never blocks on, follows, or over-reads a committed term file; a FIFO, link or oversized term the scan reads would show it wrong diff --git a/.abcd/work/issues/open/iss-2609012037137250-sibling-of-ghsa-865x-5m7q-qm79-cwe-59-found-on-the-sweep-and.md b/.abcd/work/issues/resolved/iss-2609012037137250-sibling-of-ghsa-865x-5m7q-qm79-cwe-59-found-on-the-sweep-and.md similarity index 67% rename from .abcd/work/issues/open/iss-2609012037137250-sibling-of-ghsa-865x-5m7q-qm79-cwe-59-found-on-the-sweep-and.md rename to .abcd/work/issues/resolved/iss-2609012037137250-sibling-of-ghsa-865x-5m7q-qm79-cwe-59-found-on-the-sweep-and.md index c91ef4a1f..532074d0c 100644 --- a/.abcd/work/issues/open/iss-2609012037137250-sibling-of-ghsa-865x-5m7q-qm79-cwe-59-found-on-the-sweep-and.md +++ b/.abcd/work/issues/resolved/iss-2609012037137250-sibling-of-ghsa-865x-5m7q-qm79-cwe-59-found-on-the-sweep-and.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/lifecycle.go" +resolution: "Fixed: the spec store's ensureDir and its lock-only mode prove every level from the repository root (fsutil.EnsureRealDirAll, ProbeRealDirAll), so a symlinked .abcd, .abcd/development or specs/ is refused rather than followed; the intent side was closed in 24c2f2e3 (iss-2609091128479544). The sweep found and fixed the same create sequence in decide's ADR mint lock and scribe's in-repo run directory." +impact: fix +resolved_by: + commit: "976e04c07" --- Sibling of GHSA-865x-5m7q-qm79 (CWE-59), found on the sweep and not fixed there: intent ensureRealDir (internal/core/intent/lifecycle.go) and spec ensureDir (internal/core/spec/store.go) refuse a symlinked LEAF directory and then os.MkdirAll the path, so a committed symlink at an ancestor (.abcd, .abcd/development, or intents/ and specs/ themselves) redirects the store create and every subsequent write — including the draft that capture promote mints through intent.CreateDraft — outside the checkout. Both functions carry a NOTE naming this as a low-severity follow-up under the trusted-worktree model; that model exists only in those comments and a research note, not in an ADR, and the same class is already ruled in scope for memory (ad2f1a8e), ahoy (81f81f67) and now the issue ledger. The fix is the per-segment Lstat walk from repoRoot that memory.memoryDir and the capture ledger use, one level at a time with os.Mkdir, with a test per store that symlinks .abcd/development out of tree and asserts a refusal and an empty target. Record-store containment as a structural rule is iss-2608301308367566; this record names the two create sites that rule would close. + +## Grounds + +- pursued: a committed ancestor symlink can no longer redirect a spec, ADR or scribe write out of the checkout; a test that plants .abcd/development (or .abcd/.work.local) as a link and still finds anything under its target would show it wrong diff --git a/.abcd/work/issues/open/iss-2609012037440084-the-docs-cite-refresh-fetcher-in-cite-fetch-go-re-guards-the.md b/.abcd/work/issues/resolved/iss-2609012037440084-the-docs-cite-refresh-fetcher-in-cite-fetch-go-re-guards-the.md similarity index 56% rename from .abcd/work/issues/open/iss-2609012037440084-the-docs-cite-refresh-fetcher-in-cite-fetch-go-re-guards-the.md rename to .abcd/work/issues/resolved/iss-2609012037440084-the-docs-cite-refresh-fetcher-in-cite-fetch-go-re-guards-the.md index a8d4e6bef..0a4540be1 100644 --- a/.abcd/work/issues/open/iss-2609012037440084-the-docs-cite-refresh-fetcher-in-cite-fetch-go-re-guards-the.md +++ b/.abcd/work/issues/resolved/iss-2609012037440084-the-docs-cite-refresh-fetcher-in-cite-fetch-go-re-guards-the.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/cite/fetch.go" +resolution: "Fixed: the cite refresh fetcher's redirect policy refuses a hop that leaves https once any earlier request in the chain was https, ahead of the host guard, and routes the refusal to the manual queue as blocked rather than recording the citation as broken. An http citation and an http-to-https upgrade are followed as before (the pin is against a downgrade)." +impact: fix +resolved_by: + commit: "8eba5f4d8" --- The docs cite refresh fetcher in cite/fetch.go re-guards the host per redirect hop through urlguard.CheckHostWith but pins no scheme, so an https link that redirects to plaintext http is followed — the same shape GHSA-35fj-9w6f-7h62 closes in memory ingest. It fetches documented links for liveness and stores no content, so it sits outside that advisory and is recorded here instead of fixed. A fix would refuse a hop whose URL scheme is not https before the host guard, as update.go and memory ingest do. + +## Grounds + +- pursued: no plaintext hop after an https one is ever requested, so no forged final_url reaches the baseline; a redirect chain https->http that still produces a request to the http host, or an ok outcome, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609020352438590-both-containment-checks-that-decide-whether-abcd-runs-a-fore.md b/.abcd/work/issues/resolved/iss-2609020352438590-both-containment-checks-that-decide-whether-abcd-runs-a-fore.md similarity index 74% rename from .abcd/work/issues/open/iss-2609020352438590-both-containment-checks-that-decide-whether-abcd-runs-a-fore.md rename to .abcd/work/issues/resolved/iss-2609020352438590-both-containment-checks-that-decide-whether-abcd-runs-a-fore.md index 55691b521..187403529 100644 --- a/.abcd/work/issues/open/iss-2609020352438590-both-containment-checks-that-decide-whether-abcd-runs-a-fore.md +++ b/.abcd/work/issues/resolved/iss-2609020352438590-both-containment-checks-that-decide-whether-abcd-runs-a-fore.md @@ -9,6 +9,10 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "hooks/hooks.json" +resolution: "Fixed shape 2 in the hook shims: every PATH-resolving event also refuses a candidate whose own file is world-writable, judged through a symlink to the file it names (ls -ldL). The remaining shapes were closed by later work: the working-directory containment, the relative PATH element the two shells read differently, and a symlink naming an in-tree binary all end at the owned-only pin of c637a734 (GHSA-gx3m-3224-qqcv), which runs a PATH abcd only when ~/.abcd/path-entry names that exact path; dataDirHazard's artefact and ancestor shapes are closed by the ownership test on the data and cache directories (iss-2609260057111315) and the attested hash the promotion re-checks (GHSA-4q78-ccfv-f374). Residual: the directory holding a symlink's target is not judged; the path-entry pin means only a path the user's own install recorded can reach it." +impact: fix +resolved_by: + commit: "0ea3161e0" --- Both containment checks that decide whether abcd runs a foreign binary or reads a foreign cache are weaker than the prose around them claims, in two shared ways. (1) The hook shims' PATH rung (hooks/hooks.json, all four PATH-resolving events) compares the candidate binary's directory against the shim's own `pwd -P`, not against the root of the repository the session is working on, so `/vendor/bin/abcd` is refused from `` and accepted from `/sub` — the same hostile clone, a different working directory. (2) Both that rung and dataDirHazard in internal/core/ahoy/data_dir.go judge only the containing directory's mode, so a world-writable file (0777) inside an ordinary 0755 directory passes every check; on a system where the directory's owner is not the only writer of its contents, the binary that is executed is still anyone's to replace. A fix must establish that containment is measured against the repository root the shim is protecting (git rev-parse --show-toplevel, or the harness's project dir), and that the trust test covers the artefact's own mode and ownership, not just its parent's. @@ -39,3 +43,6 @@ this with the other two. non-sticky ANCESTOR (rename-and-substitute) and a world-writable artefact file inside a 0755 cache both pass. +## Grounds + +- pursued: a PATH abcd that any local user can rewrite is never executed by a hook; a recorded 0777 binary, or a symlink to one, that a hook still runs would show it wrong diff --git a/.abcd/work/issues/open/iss-2609020735520603-the-v0-7-1-brief-surface-crosscheck-direction-a-over-chapter.md b/.abcd/work/issues/resolved/iss-2609020735520603-the-v0-7-1-brief-surface-crosscheck-direction-a-over-chapter.md similarity index 52% rename from .abcd/work/issues/open/iss-2609020735520603-the-v0-7-1-brief-surface-crosscheck-direction-a-over-chapter.md rename to .abcd/work/issues/resolved/iss-2609020735520603-the-v0-7-1-brief-surface-crosscheck-direction-a-over-chapter.md index d7305e375..ab99a5f22 100644 --- a/.abcd/work/issues/open/iss-2609020735520603-the-v0-7-1-brief-surface-crosscheck-direction-a-over-chapter.md +++ b/.abcd/work/issues/resolved/iss-2609020735520603-the-v0-7-1-brief-surface-crosscheck-direction-a-over-chapter.md @@ -9,6 +9,14 @@ found_during: "release-v0.7.1-crosscheck" origin: researcher-authored production_mode: hand-written found_at: ".abcd/development/brief/06-delivery/02-verification-matrix.md" +resolution: "Every item the record names and every one of the 28 failing entries of the crosscheck output (.abcd/work/reviews/31e9534f76e48b0fdb25110afe68dfc84d77d7d1/iss35-brief-surface-crosscheck.json) was re-checked at 8322cdf65. Already corrected at that base by earlier passes: 23-reading.md's regime-signature claim (985fddf93 removed the signature registry, and the chapter says so), the verification matrix's capture promote row and every other matrix row the output names (b-20 to b-28), 01-build-sequence.md's abcd init and config get|set attribution and its adapter scaffold and dry-run claims (b-16 to b-19), the operator-internal verb list (08-skills.md now points at 04-surfaces/README.md's list rather than counting), 03-configuration.md's dev-sync, --archive, embark scan and config/ claims (b-9 to b-13), 05-prompt-quality.md's linter paragraph (b-14), and b-1 to b-7 in 07-memory.md, 04-naming.md and 01-agents.md. Corrected here (d2f7a968e): the disembark invocation, spelt with the retired 'to' operand at sixteen sites in eight brief and glossary files, now reads 'disembark pack ', and 02-adapters.md and 04-universal-patterns.md say that scanner is the one capability seam under internal/adapter/ while oracle, history, spec, run and internal/registry are design targets, with the source-reader table naming internal/core/history and internal/core/spec. The record's closing suggestion, that the un-gated chapters join surface_coverage's scope, is a proposal rather than a defect and is not taken up here." +impact: internal +resolved_by: + commit: "d2f7a968e" --- The v0.7.1 brief-surface crosscheck (Direction A over chapters 17 to 31 and Direction B over the five surfaces, tier full) found thirty-four discrepancies, every one in prose no lint rule reads: 02-constraints/04-naming.md (4), 05-internals/01-agents.md (1), 03-configuration.md (5), 05-prompt-quality.md (4), 08-skills.md (1), 06-delivery/01-build-sequence.md (5), 02-verification-matrix.md (7), 04-surfaces/23-reading.md (2), plus four against the CLI verb tree; the eight assigned 04-surfaces chapters gated by surface_coverage were clean but for reading. The consequential ones: 23-reading.md line 128 claims every regime signature ships enforced when all four are observed (ingest_regime.go and its test record the change; the brief sentence was never updated), an overstated safety property on a shipped release; 06-delivery/02-verification-matrix.md line 55 says never a capture promote CLI sub-verb while the verb ships and 04-surfaces/README.md documents it; the disembark invocation is written as 'disembark to ' in seven places across four chapters where the real shape is 'disembark pack ' with no 'to'; an internal/adapter/{oracle,history,spec,run,scanner} plus internal/registry layout is asserted in two chapters and does not exist; 06-delivery/01-build-sequence.md line 38 attributes abcd init and abcd config get|set to the shipped install milestone and neither exists; the operator-internal verb count disagrees between 08-skills.md (five) and 04-surfaces/README.md (three, correct). The full list with claim and reality per item is the crosscheck output kept with the v0.7.1 receipts. Worth deciding whether the un-gated chapters join the surface_coverage rule's scope rather than being corrected by hand again. + +## Grounds + +- pursued: no brief chapter spells a disembark invocation with a 'to' operand or presents internal/adapter/{oracle,history,spec,run} or internal/registry as present; a grep of the brief for either printing a line outside an explicit design-target statement would show it wrong diff --git a/.abcd/work/issues/open/iss-2609020833531987-docs-reference-writing-style-md-gained-a-structure-table-row.md b/.abcd/work/issues/resolved/iss-2609020833531987-docs-reference-writing-style-md-gained-a-structure-table-row.md similarity index 61% rename from .abcd/work/issues/open/iss-2609020833531987-docs-reference-writing-style-md-gained-a-structure-table-row.md rename to .abcd/work/issues/resolved/iss-2609020833531987-docs-reference-writing-style-md-gained-a-structure-table-row.md index 52ad495a8..80e12f599 100644 --- a/.abcd/work/issues/open/iss-2609020833531987-docs-reference-writing-style-md-gained-a-structure-table-row.md +++ b/.abcd/work/issues/resolved/iss-2609020833531987-docs-reference-writing-style-md-gained-a-structure-table-row.md @@ -9,6 +9,14 @@ found_during: "release-v0.7.1-docs-currency" origin: researcher-authored production_mode: hand-written found_at: "docs/reference/writing-style.md" +resolution: "The Escapes section of docs/reference/writing-style.md names harness_leak's own escape: abcd-lint:allow (abcd-audit:allow honoured too), the docs-lint allow comment not read, a fenced block never flagged — matching checkHarnessLeak in internal/core/lint/harnessleak.go. No gate would have caught it: docs-lint checks tokens and links, not whether a reference page's claims match the rule set." +impact: fix +resolved_by: + commit: "120d11000" --- docs/reference/writing-style.md gained a Structure-table row for harness_leak at v0.7.1 but its Escapes section (lines 81 to 90) still lists the docs-lint allow families as present_tense, punctuation, spelling, harness and names, and says the other machine-enforced rules have no line escape; harness_leak is in neither list, and it does take a line escape under a different token: checkHarnessLeak skips a line carrying abcd-lint:allow (or the pre-spc-29 abcd-audit:allow) and does not honour the docs-lint allow comment, and a fenced block is never flagged. A reader applies the wrong escape token or believes none exists. One sentence after line 88 closes it. Found by the v0.7.1 docs-currency pass on the release content commit; deferred from the receipt to this record. + +## Grounds + +- pursued: the paragraph states what checkHarnessLeak does (waiver tokens, mask for fences, no docs-lint allow read); a harness_leak escape the paragraph does not name, or one it names that the rule ignores, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609090951283654-two-hand-kept-copies-of-the-owned-declaration-reader.md b/.abcd/work/issues/resolved/iss-2609090951283654-two-hand-kept-copies-of-the-owned-declaration-reader.md similarity index 74% rename from .abcd/work/issues/open/iss-2609090951283654-two-hand-kept-copies-of-the-owned-declaration-reader.md rename to .abcd/work/issues/resolved/iss-2609090951283654-two-hand-kept-copies-of-the-owned-declaration-reader.md index 9f8fb3be1..d09232597 100644 --- a/.abcd/work/issues/open/iss-2609090951283654-two-hand-kept-copies-of-the-owned-declaration-reader.md +++ b/.abcd/work/issues/resolved/iss-2609090951283654-two-hand-kept-copies-of-the-owned-declaration-reader.md @@ -9,6 +9,14 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "internal/core/rules/root.go" +resolution: "The entry match, the refusal-to-reason switch and resolveOrClean are lifted into fsutil.HomeDeclarationNames; the rules and history readers call it and keep only their wording. The five file checks were already lifted into ReadHomeDeclaration at the base (iss-2609091927085132)." +impact: internal +resolved_by: + commit: "cd89fa4536c529ecda0d640f8347e5287e497c2f" --- The user-scope declaration reader now exists twice, hand-kept: trustedRootDeclared in the rules resolver and localDeclared in the transcript-store locator run the same five checks in the same order, an lstat for a regular file, a refusal of group or world write, a requirement that the caller own it, a read through the guarded reader under a byte cap, and a comparison of each absolute entry against the target in both its written and its symlink-resolved spelling under the platform fold. Each also carries its own ignored-declaration renderer and its own verbatim copy of resolveOrClean. They have already diverged in one place: the rules copy owns a local ownership seam so a test can force a foreign uid, and the history copy calls the canonical lookup directly, so the refusal branch is provable in one package and not in the other. It matters because this is a trust boundary whose next hardening, a caller-owned parent requirement or a same-file re-check between the ownership stat and the read, will land in whichever copy the fixer happens to be looking at, and the omission compiles green in both. Fix direction: lift the reader into one primitive taking the declaration location and the value to match and returning the verdict plus the ignored-declaration reason, and have both packages call it. Detector: a change to the declaration reader checks must be visible to both callers through one definition, so a test that removes a check fails in both packages. + +## Grounds + +- pursued: both callers answer every declaration identically through one definition; a second copy of the match reappearing in either package, or a mutation of the primitive passing the rules or history fold tests, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609090951295881-ideate-enum-refusals-echo-the-raw-invalid-field-value.md b/.abcd/work/issues/resolved/iss-2609090951295881-ideate-enum-refusals-echo-the-raw-invalid-field-value.md similarity index 74% rename from .abcd/work/issues/open/iss-2609090951295881-ideate-enum-refusals-echo-the-raw-invalid-field-value.md rename to .abcd/work/issues/resolved/iss-2609090951295881-ideate-enum-refusals-echo-the-raw-invalid-field-value.md index 6deb2c23d..b9fa11201 100644 --- a/.abcd/work/issues/open/iss-2609090951295881-ideate-enum-refusals-echo-the-raw-invalid-field-value.md +++ b/.abcd/work/issues/resolved/iss-2609090951295881-ideate-enum-refusals-echo-the-raw-invalid-field-value.md @@ -9,6 +9,14 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ideate/record.go" +resolution: "Fixed: the six closed-set refusals in the ideate recorder (verdict, leg kind, claim status, grill relation, kill outcome, and a grill hit's record id) describe the refused value by its length and never quote it, through termsafe.DescribeRefused; the refusal still names the field and the admitted set. The same shape in lifeboat review and intent audit was captured and fixed as iss-2609290033521472." +impact: fix +resolved_by: + commit: "a182bb7ca" --- The ideate verdict recorder states its redaction discipline in the stage-two refusal type: a refusal that quoted the span it refused would publish the leak into the error message, and from there into a terminal and a log, so that error names the finding kinds and nothing else. The enum refusals in the same file do the opposite. Five sites, the verdict, the leg kind, the claim status, the grill relation and the kill outcome, interpolate the offending value into the message after passing it only through the terminal-escape cleaner, which strips control sequences and does not redact. Every one of those fields arrives in the host payload alongside the free-text fields the recorder does redact field by field, so a payload whose status field carries a token or a home path is refused with nothing written and that value printed verbatim to the error surface, where the session transcript and any capture outside abcd take it raw. Verified by reading the five call sites. It matters because the file argues at length that a fail-closed promise is only honest if the refusal itself carries nothing, and these five refusals are the exception nobody wrote down. Fix direction: describe the offending value rather than quoting it, since its length and whether it is empty is enough to fix a typo, or route it through the same field redactor the prose fields already use before it reaches the message. Detector: an ideate payload whose enum field carries a token-shaped value must be refused without that value appearing anywhere in the error text. + +## Grounds + +- pursued: no ideate refusal carries a refused closed-set value; a payload whose enum field holds a sentinel that appears in the error would show it wrong diff --git a/.abcd/work/issues/open/iss-2609090951297149-fold-branch-of-the-declaration-compare-has-no-test-seam.md b/.abcd/work/issues/resolved/iss-2609090951297149-fold-branch-of-the-declaration-compare-has-no-test-seam.md similarity index 73% rename from .abcd/work/issues/open/iss-2609090951297149-fold-branch-of-the-declaration-compare-has-no-test-seam.md rename to .abcd/work/issues/resolved/iss-2609090951297149-fold-branch-of-the-declaration-compare-has-no-test-seam.md index b1bba5227..e4326f806 100644 --- a/.abcd/work/issues/open/iss-2609090951297149-fold-branch-of-the-declaration-compare-has-no-test-seam.md +++ b/.abcd/work/issues/resolved/iss-2609090951297149-fold-branch-of-the-declaration-compare-has-no-test-seam.md @@ -9,6 +9,14 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "internal/core/rules/root.go" +resolution: "rules and history hold the fold predicate in a package var (caseFoldingFS) passed to fsutil.HomeDeclarationNames; a case-variant declaration is honoured with it forced on and refused with it forced off, in both packages and in the primitive." +impact: internal +resolved_by: + commit: "cd89fa4536c529ecda0d640f8347e5287e497c2f" --- Both user-scope declaration readers call the exported case-folding predicate directly when comparing a declared path against the target: the rules resolver and the transcript-store locator each ask whether the filesystem folds case and then compare under that answer. Two sibling packages hold the same predicate in a package-level variable for the stated reason that substituting it is the only way a test can exercise the branch, and the path utilities package keeps one for its own redactor. Neither of these two does, so on a case-sensitive host the fold-equality branch of the declaration compare is unreachable by any test, and what it does with a case-variant spelling of a declared root is pinned nowhere. Verified by grepping the seam: it exists in the path utilities, the ahoy package and the update package, and not in rules or history. It matters because the declaration is the opt-in that re-admits a foreign-owned checkout, so a fold bug there either silently admits a root the caller never declared or silently refuses one they did, and the case-sensitive CI leg would show neither. Fix direction: hold the predicate in a package variable in both packages, exactly as the two siblings already do, and add the case-variant declaration case each seam makes possible. Detector: with the fold predicate forced on, a declaration written in a case variant of the target must be honoured, and with it forced off it must not. + +## Grounds + +- pursued: the case-variant branch of the declaration match is now pinned on a case-sensitive host; a fold mutation of the primitive passing TestTrustedRootsCaseVariantFollowsTheFoldPredicate or TestLocalTranscriptRootsCaseVariantFollowsTheFoldPredicate would show it wrong diff --git a/.abcd/work/issues/open/iss-2609100508573400-ideate-record-prints-does-not-graduate-for-a-reframed-verdict.md b/.abcd/work/issues/resolved/iss-2609100508573400-ideate-record-prints-does-not-graduate-for-a-reframed-verdict.md similarity index 66% rename from .abcd/work/issues/open/iss-2609100508573400-ideate-record-prints-does-not-graduate-for-a-reframed-verdict.md rename to .abcd/work/issues/resolved/iss-2609100508573400-ideate-record-prints-does-not-graduate-for-a-reframed-verdict.md index 2b4bfbc4d..488772c32 100644 --- a/.abcd/work/issues/open/iss-2609100508573400-ideate-record-prints-does-not-graduate-for-a-reframed-verdict.md +++ b/.abcd/work/issues/resolved/iss-2609100508573400-ideate-record-prints-does-not-graduate-for-a-reframed-verdict.md @@ -9,6 +9,10 @@ found_during: "autonomous-run field experiment in a managed repository, 2026-09- origin: researcher-authored production_mode: hand-written found_at: "internal (ideate record)" +resolution: "renderIdeateResult gives each verdict its own next line: a reframed verdict now reads 'the idea as posed does not graduate, but its reframing may', naming abcd intent with the reframing the record carries rather than the original wording, matching the record's own What follows section and commands/ideate.md. Survives and killed lines and the JSON envelope are unchanged." +impact: fix +resolved_by: + commit: "56e49ff59" --- `abcd ideate record` prints "the idea does not graduate" for a `reframed` verdict, which reads as "killed" to the operator receiving it. @@ -16,3 +20,7 @@ found_at: "internal (ideate record)" Observed recording an ideate verdict during an autonomous run in a managed repository. A `reframed` verdict is not a rejection: the idea survives in a different shape, and the whole point of recording it is that the reframing is the output. The message conflates it with the outcome where nothing survives, so an operator (or a downstream session reading the record) takes a reframed idea for a dead one. Wanted: distinct wording per verdict — say what the reframing was, or at minimum "the idea does not graduate in its original shape; it was reframed" — so the terminal message matches the verdict the record carries. This is the only defect the run found in a set of record verbs that otherwise all worked, which is filed separately as a positive observation. + +## Grounds + +- pursued: we expect a reframed verdict's terminal line to be distinguishable from a killed one's and to name the reframing; a reframed record whose next line reads as a rejection would show it wrong diff --git a/.abcd/work/issues/open/iss-2609231101102072-the-verification-matrix-keeps-pre-rename-vocabulary-for-two.md b/.abcd/work/issues/resolved/iss-2609231101102072-the-verification-matrix-keeps-pre-rename-vocabulary-for-two.md similarity index 51% rename from .abcd/work/issues/open/iss-2609231101102072-the-verification-matrix-keeps-pre-rename-vocabulary-for-two.md rename to .abcd/work/issues/resolved/iss-2609231101102072-the-verification-matrix-keeps-pre-rename-vocabulary-for-two.md index ea1a9dcbc..a85e494f3 100644 --- a/.abcd/work/issues/open/iss-2609231101102072-the-verification-matrix-keeps-pre-rename-vocabulary-for-two.md +++ b/.abcd/work/issues/resolved/iss-2609231101102072-the-verification-matrix-keeps-pre-rename-vocabulary-for-two.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written +resolution: "The verification matrix rows read 'Intent audit (Role 1)' and 'Press-release review' (its staged half appends the lifeboat review's findings), and the two further live brief lines that called the owed queue 'owed fidelity reviews' say 'owed intent audits', matching abcd intent audit --owed. No gate would have caught it: the surface_coverage rule checks verb rows in the surface chapters, not the matrix's row titles." +impact: fix +resolved_by: + commit: "7f292439e" --- The verification matrix keeps pre-rename vocabulary for two renamed verbs: its rows read 'Intent fidelity review (Role 1)' and 'Press-release oracle audit', while the intent audit (itd-123, ac-2: brief prose moves to the audit vocabulary) and the lifeboat review (itd-125, ac-5: the brief's oracle-audit prose moves to review) each promised the brief's current-state prose would move. Found by the fidelity audit of both intents; the page is 06-delivery/02-verification-matrix.md. + +## Grounds + +- pursued: grep over .abcd/development/brief for 'fidelity review' and 'oracle audit' finds only the itd-48 Role 2 design name, which no rename touched; a current-state brief line naming the renamed verb or agent by its old spelling would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609231526392449-guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid.md b/.abcd/work/issues/resolved/iss-2609231526392449-guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid.md new file mode 100644 index 000000000..7ceae87cf --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609231526392449-guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609231526392449" +slug: "guard-hook-reads-abcd-guard-json-from-a-refused-foreign-uid" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +resolution: "Resolved on its merits; the wording was the defect, not the read. AGENTS.md 'Foreign-uid roots' states that the refusal bounds the walk, not the working directory, so a session started AT a refused root reads that root's .abcd/ configuration by design, and the posture change that would refuse that read too stays deferred (DECISIONS.md 2026-09-25, the unmerged feat/ruled-security-forks). The note that claimed the root's guard.json was NOT read was corrected at base by ec5ca74f8 (v0.11.1, iss-2609251522588539, iss-2609261753290536): where the working directory carries a real .abcd/ the note says it IS read and governs the session. The guard's per-call workdir goes through the same rules.Resolve (rulesRoot), so the note printed for a workdir at the refused root is computed for that workdir and says the same; a workdir's registry can only add hazards (guard.Strictest), and its text reaches the agent through termsafe.Sanitize. Pinned by TestResolveRootRefusalSaysWhatItStillReads." +impact: fix +shipped_in: v0.11.1 +resolved_by: + commit: "ec5ca74f8" +--- + +guard hook reads .abcd/guard.json from a REFUSED foreign-uid root when the workdir (or session cwd) is that root, while the refusal note says it was not read (internal/surface/cli/guard.go:430, internal/core/rules/root.go:255); it can only add hazards (Strictest), but the foreign file's why/successor text reaches the agent as the block message. Fixed on feat/ruled-security-forks (da3efea6/ebc6290d), not on main. + +## Grounds + +- pursued: the refusal note never says a configuration went unread that the loaders read; a session or workdir at a refused root with its own .abcd/ whose note says NOT read would show it wrong diff --git a/.abcd/work/issues/open/iss-2609231931006041-itd-147-ac-3-fidelity-audit-the-generated-appendix-of-the.md b/.abcd/work/issues/resolved/iss-2609231931006041-itd-147-ac-3-fidelity-audit-the-generated-appendix-of-the.md similarity index 54% rename from .abcd/work/issues/open/iss-2609231931006041-itd-147-ac-3-fidelity-audit-the-generated-appendix-of-the.md rename to .abcd/work/issues/resolved/iss-2609231931006041-itd-147-ac-3-fidelity-audit-the-generated-appendix-of-the.md index 9c534f5e0..9f71cca28 100644 --- a/.abcd/work/issues/open/iss-2609231931006041-itd-147-ac-3-fidelity-audit-the-generated-appendix-of-the.md +++ b/.abcd/work/issues/resolved/iss-2609231931006041-itd-147-ac-3-fidelity-audit-the-generated-appendix-of-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: ".abcd/development/brief/04-surfaces/13-consult.md" +resolution: "The appendix generator reads the register's Status cell: a command the tree does not register gets HostDelegatedSentence when its row reads shipped and UnbuiltSentence otherwise (internal/core/surface/appendix.go, Chapter.Appendix). 13-consult.md, 14-ingest.md and 15-prepare-this-repo.md are regenerated and say they ship as host-delegated command pages; 09-reflect.md and the staged worktree row keep the unbuilt sentence. TestShippedChapterNeverClaimsNoShippedSurface (internal/surface/cli) fails on any shipped chapter that says there is no shipped surface, and was watched fail on the three chapters before the change. The register prose in 04-surfaces/README.md states both sentences." +impact: internal +resolved_by: + commit: "7a7ea0130" --- itd-147 ac-3 (fidelity audit): the generated appendix of the three host-delegated commands says 'There is no shipped surface' while their register rows read shipped. UnbuiltSentence (internal/core/surface/appendix.go) is emitted for any chapter whose command the Go tree does not register, and /abcd:consult, /abcd:ingest and /abcd:prepare-this-repo are shipped host-delegated commands with a command page and no Go verb (04-surfaces/README.md, No skills). The block therefore states a false claim about the surface — the class the intent exists to remove — in 13-consult.md, 14-ingest.md and 15-prepare-this-repo.md. A host-delegated chapter needs its own sentence: shipped as a command page, with no Go verb and so no flags or sub-verbs to list; the generator can tell the cases apart from the register's Status column, which it already parses. + +## Grounds + +- pursued: every chapter whose register row reads shipped carries no 'no shipped surface' sentence; a new host-delegated command added with a shipped row and the old sentence would show it wrong, and the test fails on exactly that diff --git a/.abcd/work/issues/open/iss-2609240227150859-itd-6-implementation-status-describes-an-mcpbridge-the-binary-lacks.md b/.abcd/work/issues/resolved/iss-2609240227150859-itd-6-implementation-status-describes-an-mcpbridge-the-binary-lacks.md similarity index 58% rename from .abcd/work/issues/open/iss-2609240227150859-itd-6-implementation-status-describes-an-mcpbridge-the-binary-lacks.md rename to .abcd/work/issues/resolved/iss-2609240227150859-itd-6-implementation-status-describes-an-mcpbridge-the-binary-lacks.md index 62b79c77e..0d71892cd 100644 --- a/.abcd/work/issues/open/iss-2609240227150859-itd-6-implementation-status-describes-an-mcpbridge-the-binary-lacks.md +++ b/.abcd/work/issues/resolved/iss-2609240227150859-itd-6-implementation-status-describes-an-mcpbridge-the-binary-lacks.md @@ -9,6 +9,14 @@ found_during: "autonomous run A, planning briefs" origin: researcher-authored production_mode: hand-written found_at: ".abcd/development/intents/planned/itd-6-rp-mcp-only-integration.md" +resolution: "itd-6's Status paragraph, Resolved (post-spc-5) section and Implementation status say no part of the RP MCP route is built: RPUnavailable, MCPBridge and oracle.py belong to an earlier Python lineage's spc-5 and ADR-02/03, the settled answers stand as design input only, the Codex fall-through defers to the re-filed Decisions, and spc-2609211950427074 carries all four re-filed criteria. No gate would have caught it: no lint compares an intent's implementation claims with the tree." +impact: internal +resolved_by: + commit: "3468a4bae" --- itd-6's Implementation status and its post-spc-5 Status paragraph say spc-5 built a typed RPUnavailable error in internal/core and a concrete MCPBridge (the ADR-02 spawn implementation and the ADR-03 host-reuse path), and its Resolved sections route failures through oracle.py. None of it is in the binary: grep for MCPBridge, RPUnavailable and RepoPrompt over internal/ and cmd/ finds only the scanner's RepoPrompt sessionKey pattern (internal/adapter/scanner/patterns.go) and a guard corpus line, and go.mod carries no MCP dependency. The sections describe an earlier Python lineage, so a planner or an implementer reading the planned record is told a foundation exists that does not; the re-filed scope (spc-2609211950427074) needs that foundation built from nothing. + +## Grounds + +- pursued: every implementation claim left in itd-6 is checkable against the tree (grep MCPBridge, RPUnavailable over internal/ and cmd/, go.mod's requires); a sentence still telling a planner a bridge or typed error exists would show it wrong diff --git a/.abcd/work/issues/open/iss-2609240227447110-itd-7-depends-on-a-dev-sync-verb-and-an-embark-route-that-do-not-exist.md b/.abcd/work/issues/resolved/iss-2609240227447110-itd-7-depends-on-a-dev-sync-verb-and-an-embark-route-that-do-not-exist.md similarity index 54% rename from .abcd/work/issues/open/iss-2609240227447110-itd-7-depends-on-a-dev-sync-verb-and-an-embark-route-that-do-not-exist.md rename to .abcd/work/issues/resolved/iss-2609240227447110-itd-7-depends-on-a-dev-sync-verb-and-an-embark-route-that-do-not-exist.md index 8c760923f..4314d3928 100644 --- a/.abcd/work/issues/open/iss-2609240227447110-itd-7-depends-on-a-dev-sync-verb-and-an-embark-route-that-do-not-exist.md +++ b/.abcd/work/issues/resolved/iss-2609240227447110-itd-7-depends-on-a-dev-sync-verb-and-an-embark-route-that-do-not-exist.md @@ -9,6 +9,14 @@ found_during: "autonomous run A, planning briefs" origin: researcher-authored production_mode: hand-written found_at: ".abcd/development/intents/planned/itd-7-rp-workspace-portability.md" +resolution: "itd-7's waiting note now records both missing prerequisites beside itd-6: no dev-sync verb exists (the command tree carries none; only the draft itd-13 describes it), and embark writes only the four record families in embarkFamilies (internal/core/lifeboat/embark_types.go), so .abcd/rp/workspace.json has no route from a lifeboat into a target repository as acceptance criteria 6 and 7 require. Both facts re-checked at 8322cdf65. No gate reads a planned intent's prose for its prerequisites, so none would have caught this; the correction is to the record, and building either prerequisite stays with itd-13 and a future embark change." +impact: internal +resolved_by: + commit: "efe9ad162" --- itd-7 (planned, spec_id null) hangs its workspace pull on abcd dev-sync, which has no verb and no code: the CLI's command list carries no dev-sync, grep over internal/ and cmd/ finds none, and the only record of it is the draft itd-13 (scheduled dev-sync). Its lifeboat route does not exist either: embark writes only four record families, adrs, issues, intents and specs (embarkFamilies in internal/core/lifeboat/embark_types.go), and names every other file as one it does not write, so .abcd/rp/workspace.json has no path from a lifeboat into a target repository as acceptance criteria 6 and 7 require. itd-6 Decision 4 already has itd-7 waiting; the record does not say that its own two prerequisites are missing as well. + +## Grounds + +- pursued: a reader of itd-7 learns every missing prerequisite from the record itself; a dev-sync verb or an embark route for workspace.json landing without the note being updated would show it stale diff --git a/.abcd/work/issues/open/iss-2609240307549105-itd-4-ac5-says-capture-list-open-lists-every-open-issue-with.md b/.abcd/work/issues/resolved/iss-2609240307549105-itd-4-ac5-says-capture-list-open-lists-every-open-issue-with.md similarity index 55% rename from .abcd/work/issues/open/iss-2609240307549105-itd-4-ac5-says-capture-list-open-lists-every-open-issue-with.md rename to .abcd/work/issues/resolved/iss-2609240307549105-itd-4-ac5-says-capture-list-open-lists-every-open-issue-with.md index 19e86b748..4e8ec4fd5 100644 --- a/.abcd/work/issues/open/iss-2609240307549105-itd-4-ac5-says-capture-list-open-lists-every-open-issue-with.md +++ b/.abcd/work/issues/resolved/iss-2609240307549105-itd-4-ac5-says-capture-list-open-lists-every-open-issue-with.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/cli.go" +resolution: "The human render of capture list now ends each row with a one-line summary: the first non-blank line of the body, sanitised through termsafe and clipped to 80 runes (summaryNote in internal/surface/cli/cli.go). TestCaptureListOpenHumanRenderCarriesSummary pins the human surface alongside the existing --json pin, and was watched fail on the summary-less row before the change. The capture chapter's list criterion names the summary." +impact: fix +resolved_by: + commit: "d12cc627c" --- itd-4 AC5 says `capture list --open` lists every open issue with id, slug, severity and a one-line summary. The human render prints id, status, severity and slug and no summary (internal/surface/cli/cli.go, the list render's Fprintf); only `--json` carries the body. spc-6's AC5 pin, TestCaptureListOpenRendersIssueFields, asserts the --json shape alone, so the shipped criterion is covered on one of its two surfaces. Found during the fidelity audit of itd-4 (receipt rcp-2662745d5344), criterion ac-5. + +## Grounds + +- pursued: every human list row carries its record's first body line; a row with a non-empty body and no ' — ' tail, or a multi-line row, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md b/.abcd/work/issues/resolved/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md similarity index 64% rename from .abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md rename to .abcd/work/issues/resolved/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md index 05bc81c5e..d0c8789bb 100644 --- a/.abcd/work/issues/open/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md +++ b/.abcd/work/issues/resolved/iss-2609251235119402-abcd-intent-audit-reads-the-issue-ledger-its-issue-drift.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli" +resolution: "runIssueDrift names the checkout and branch whose ledger it read, with ledgerIdentityOf and renderLedger: a stderr line in the text render and a ledger member on the --json envelope, as every capture verb does." +impact: fix +resolved_by: + commit: "62140a148" --- abcd intent audit reads the issue ledger (its issue-drift form checks the promote join) without naming which checkout's ledger and branch it read. Decomposed out of iss-2609202053570475, which made every capture verb and the record dispatcher name the ledger they addressed: the audit's output is the intent-auditor verdict envelope, owned by the intent surface, so adding the member there is that surface's change. The helpers to reuse are ledgerIdentityOf and renderLedger in internal/surface/cli. + +## Grounds + +- pursued: we expect intent audit --issue-drift to name the ledger's checkout and branch in both renders; a run that reads the ledger without naming it would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251358062952-the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs.md b/.abcd/work/issues/resolved/iss-2609251358062952-the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs.md similarity index 53% rename from .abcd/work/issues/open/iss-2609251358062952-the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs.md rename to .abcd/work/issues/resolved/iss-2609251358062952-the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs.md index 1bae2f897..b33e9400d 100644 --- a/.abcd/work/issues/open/iss-2609251358062952-the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs.md +++ b/.abcd/work/issues/resolved/iss-2609251358062952-the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: ".github/workflows/ci.yml" +resolution: "The comment above the record-lint job in .github/workflows/ci.yml names the three ledger gates it runs (RD001-RD004, RS001-RS006, DA001-DA004), says the design-record drift gate cmd/record-lint is a step of the check job, and says the id stays because it is the required status check the main-protection ruleset names, so a rename lands only with the live ruleset edit. Resolved by wording: the check keeps its name on the forge, which a rename would change only together with a ruleset edit nobody has asked for. No gate would have caught it." +impact: internal +resolved_by: + commit: "f3665abbd" --- The CI job id record-lint in .github/workflows/ci.yml runs scripts/check-reviews-cases.sh and scripts/check-reviews.sh (the reviews-charter gate), while the real record-lint is a step of the check job, so a required status check is named for a gate it does not run. Renaming the job alone breaks the merge gate, because the main-protection ruleset (mirrored in .abcd/work/rulesets/main-protection.json) requires the context record-lint: the rename and the live ruleset edit must land together, which needs the forge ruleset changed by someone holding that permission. Carried over from iss-304 (d-12) when its other two halves were closed. + +## Grounds + +- pursued: a reader of ci.yml learns from the job's own comment what the record-lint check runs and why it is so named; a gate the job runs that the comment omits, or a claim that the id can be renamed alone, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251606543515-the-spec-close-remedy-prints-the-verb-twice-abcd-abcd-spec.md b/.abcd/work/issues/resolved/iss-2609251606543515-the-spec-close-remedy-prints-the-verb-twice-abcd-abcd-spec.md similarity index 54% rename from .abcd/work/issues/open/iss-2609251606543515-the-spec-close-remedy-prints-the-verb-twice-abcd-abcd-spec.md rename to .abcd/work/issues/resolved/iss-2609251606543515-the-spec-close-remedy-prints-the-verb-twice-abcd-abcd-spec.md index fcab63d75..1dfa62f88 100644 --- a/.abcd/work/issues/open/iss-2609251606543515-the-spec-close-remedy-prints-the-verb-twice-abcd-abcd-spec.md +++ b/.abcd/work/issues/resolved/iss-2609251606543515-the-spec-close-remedy-prints-the-verb-twice-abcd-abcd-spec.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "The routing resolver strips a leading 'abcd ' from the verb before naming it on its fallback and diagnostic stderr lines, so every caller spelling prints the verb once; TestRouteStderrNamesTheVerbOnce pins it." +impact: fix +resolved_by: + commit: "94f02fce5" --- The spec-close remedy prints the verb twice ("abcd abcd spec close"): route.go:259 prefixes an already-prefixed verb string, and the same doubling stands at cli.go:2360, cli.go:2397 and ship.go:331 (review2-tier2 note a). + +## Grounds + +- pursued: every routed verb's stderr line names the verb exactly once; a line reading 'abcd abcd' from any routed verb would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251606553359-reading-ingest-with-route-refuses-an-oversized-output-as.md b/.abcd/work/issues/resolved/iss-2609251606553359-reading-ingest-with-route-refuses-an-oversized-output-as.md similarity index 50% rename from .abcd/work/issues/open/iss-2609251606553359-reading-ingest-with-route-refuses-an-oversized-output-as.md rename to .abcd/work/issues/resolved/iss-2609251606553359-reading-ingest-with-route-refuses-an-oversized-output-as.md index 2ad78aa54..7aaed932f 100644 --- a/.abcd/work/issues/open/iss-2609251606553359-reading-ingest-with-route-refuses-an-oversized-output-as.md +++ b/.abcd/work/issues/resolved/iss-2609251606553359-reading-ingest-with-route-refuses-an-oversized-output-as.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "reading ingest returns the output read's own refusal (size cap, non-regular file, unreadable) when a --route is given, instead of the no-position refusal; TestReadingIngestRouteOnAnUnreadableOutputGivesTheReadsReason pins both the routed and unrouted cases." +impact: fix +resolved_by: + commit: "ec535a9ec" --- reading ingest with --route refuses an oversized output as "names no reading position" instead of giving the size-cap message (internal/surface/cli/reading.go:246-251), so the operator is told the wrong cause (review2-tier2 note b). + +## Grounds + +- pursued: an operator whose routed ingest fails on the output file is told the file's fault; a routed ingest of an oversized output still naming the position would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251616248870-internal-actionsexpr-looseequal-compares-a-bool-to-a-string.md b/.abcd/work/issues/resolved/iss-2609251616248870-internal-actionsexpr-looseequal-compares-a-bool-to-a-string.md similarity index 56% rename from .abcd/work/issues/open/iss-2609251616248870-internal-actionsexpr-looseequal-compares-a-bool-to-a-string.md rename to .abcd/work/issues/resolved/iss-2609251616248870-internal-actionsexpr-looseequal-compares-a-bool-to-a-string.md index a96914db8..f9d2f8f83 100644 --- a/.abcd/work/issues/open/iss-2609251616248870-internal-actionsexpr-looseequal-compares-a-bool-to-a-string.md +++ b/.abcd/work/issues/resolved/iss-2609251616248870-internal-actionsexpr-looseequal-compares-a-bool-to-a-string.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "actionsexpr's == follows GitHub's documented loose-equality table: mismatched types coerce to numbers (null 0, booleans 1/0, strings by the JSON number grammar with empty as 0 else NaN, arrays and objects NaN), NaN equals nothing, strings compare ignoring case; TestLooseEqualFollowsGitHubsCoercionTable pins the table." +impact: internal +resolved_by: + commit: "e9d2c9b59" --- internal/actionsexpr looseEqual compares a bool to a string by truthiness, where GitHub Actions coerces both to numbers (true == 'true' is false on GitHub), so an if: expression making that comparison would evaluate differently in the test evaluator than in the runner (actionsexpr.go:424-429; carried over unchanged from the old lint evaluator; no committed workflow makes that comparison today; review2-workflows R1). + +## Grounds + +- pursued: the test evaluator answers every == the runner would answer the same way; a row of the documented table evaluating differently would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251616310535-the-auto-release-detect-step-s-tests-do-not-cover-a-failing.md b/.abcd/work/issues/resolved/iss-2609251616310535-the-auto-release-detect-step-s-tests-do-not-cover-a-failing.md similarity index 53% rename from .abcd/work/issues/open/iss-2609251616310535-the-auto-release-detect-step-s-tests-do-not-cover-a-failing.md rename to .abcd/work/issues/resolved/iss-2609251616310535-the-auto-release-detect-step-s-tests-do-not-cover-a-failing.md index 8d228f954..d34337b28 100644 --- a/.abcd/work/issues/open/iss-2609251616310535-the-auto-release-detect-step-s-tests-do-not-cover-a-failing.md +++ b/.abcd/work/issues/resolved/iss-2609251616310535-the-auto-release-detect-step-s-tests-do-not-cover-a-failing.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "autoreleasedetect_test.go now runs a failed gh run view (refuses, names the run) and an in-progress verify job (heals) against the committed workflow and both template profiles." +impact: internal +resolved_by: + commit: "19b64d720af68e89ad8f4649f8cb2c2b44f0c7b0" --- The auto-release detect step's tests do not cover a failing gh run view or an in-progress verify job (read to heal); both paths are read correctly but a regression in either would pass (autoreleasedetect_test.go:169-217; review2-workflows R2). + +## Grounds + +- pursued: a regression that swallows a failed run view or reads an unconcluded verify as a refusal now fails the detect table; either mutation passing it would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251645374557-the-help-renderer-computes-the-name-column-width-over-both.md b/.abcd/work/issues/resolved/iss-2609251645374557-the-help-renderer-computes-the-name-column-width-over-both.md similarity index 56% rename from .abcd/work/issues/open/iss-2609251645374557-the-help-renderer-computes-the-name-column-width-over-both.md rename to .abcd/work/issues/resolved/iss-2609251645374557-the-help-renderer-computes-the-name-column-width-over-both.md index c88cd58c8..a8cc5a832 100644 --- a/.abcd/work/issues/open/iss-2609251645374557-the-help-renderer-computes-the-name-column-width-over-both.md +++ b/.abcd/work/issues/resolved/iss-2609251645374557-the-help-renderer-computes-the-name-column-width-over-both.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "renderRootHelp sizes each block's name column over its own names (internal/surface/cli/helpgroups.go), so the person's block is byte-identical under --help and --help --agent; TestPeopleBlockIsTheSameUnderBothHelps holds it." +impact: fix +resolved_by: + commit: "8cf52a722" --- The help renderer computes the name column width over both blocks, so the person's block renders at width 12 under --help and 21 under --help --agent: the people block is not byte-stable across the two forms (internal/surface/cli/helpgroups.go:266-274; review-helpgroups 2). + +## Grounds + +- pursued: we expect the person's block to render the same bytes in both help forms; any byte difference between the two would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609251645376019-abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent.md b/.abcd/work/issues/resolved/iss-2609251645376019-abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent.md new file mode 100644 index 000000000..f062babce --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609251645376019-abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent.md @@ -0,0 +1,21 @@ +--- +schema_version: 1 +id: "iss-2609251645376019" +slug: "abcd-help-agent-refuses-with-cobra-s-bare-unknown-flag-agent" +severity: "minor" +category: "ux" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +resolution: "abcd help --agent now refuses with the same message as abcd --agent, naming abcd --help --agent, exit 2: applyHelpVerbAgentRefusal wraps the root's flag-error function, which cobra's lazily built help verb inherits, and one constant carries the refusal for both paths." +impact: fix +resolved_by: + commit: "8cf52a722" +--- + +abcd help --agent refuses with cobra's bare 'unknown flag: --agent' (exit 2), while abcd --agent refuses naming the spelling that works; the help-subcommand path does not name --help --agent (review-helpgroups 3). + +## Grounds + +- pursued: we expect every path that takes --agent without --help to name abcd --help --agent; a help-verb invocation still showing cobra's bare unknown-flag line would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251728586400-two-scanner-test-nits-from-review-3-meter-test-go-196-pins.md b/.abcd/work/issues/resolved/iss-2609251728586400-two-scanner-test-nits-from-review-3-meter-test-go-196-pins.md similarity index 56% rename from .abcd/work/issues/open/iss-2609251728586400-two-scanner-test-nits-from-review-3-meter-test-go-196-pins.md rename to .abcd/work/issues/resolved/iss-2609251728586400-two-scanner-test-nits-from-review-3-meter-test-go-196-pins.md index ae2e66c95..30f18a71a 100644 --- a/.abcd/work/issues/open/iss-2609251728586400-two-scanner-test-nits-from-review-3-meter-test-go-196-pins.md +++ b/.abcd/work/issues/resolved/iss-2609251728586400-two-scanner-test-nits-from-review-3-meter-test-go-196-pins.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "Both nits fixed in meter_test.go: a rootless over-long separator run pins the bound's over-report (fails with the loop bound removed), and the fixtures past the bound use a fixed overLongSeparatorRun guarded to stay past maxSeparatorRun." +impact: internal +resolved_by: + commit: "c43694401b04b28fd24f71ca1e32897d592ca6a8" --- Two scanner test nits from review 3: meter_test.go:196 pins the separator-run cap with a true account position (C:\Users followed by 65 backslashes), which passes with or without the cap, so the over-report direction for a rootless run is not itself pinned; and the meter fixtures scale with maxSeparatorRun, so inflating the constant to probe the no-cap case allocates gigabyte strings (remove the loop bound instead). + +## Grounds + +- pursued: removing the separator-run bound from endsWithPathFold now fails TestBoundedContextHelpersKeepTheFinding; that mutation passing, or a fixture again derived from maxSeparatorRun, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251750202525-abcd-site-and-abcd-site-build-hand-the-working-directory-to.md b/.abcd/work/issues/resolved/iss-2609251750202525-abcd-site-and-abcd-site-build-hand-the-working-directory-to.md similarity index 52% rename from .abcd/work/issues/open/iss-2609251750202525-abcd-site-and-abcd-site-build-hand-the-working-directory-to.md rename to .abcd/work/issues/resolved/iss-2609251750202525-abcd-site-and-abcd-site-build-hand-the-working-directory-to.md index bf80fdca9..ee698f6fe 100644 --- a/.abcd/work/issues/open/iss-2609251750202525-abcd-site-and-abcd-site-build-hand-the-working-directory-to.md +++ b/.abcd/work/issues/resolved/iss-2609251750202525-abcd-site-and-abcd-site-build-hand-the-working-directory-to.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "abcd site, site build and lint site resolve the checkout root (git toplevel, else the .git-marker walk) and default their output directory to the checkout's site/, so a subdirectory reads and builds the same site as the root; the sibling lint site had the same defect and is fixed in the same change; TestSiteVerbsReadTheCheckoutFromASubdirectory pins all three." +impact: fix +resolved_by: + commit: "bacec65df" --- abcd site and abcd site build hand the working directory to site.Describe/site.Build, so run from a subdirectory they report '.abcd/site.json (absent) ... nothing to build' with exit 0: a plausible wrong answer rather than a refusal, the same shape iss-2609251713073532 fixed for the release cut (internal/surface/cli/site.go:31-35, 52-58; review2-sentences). + +## Grounds + +- pursued: the site verbs answer the same from any directory of a checkout; the board or the build differing between the root and a subdirectory would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md b/.abcd/work/issues/resolved/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md similarity index 60% rename from .abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md rename to .abcd/work/issues/resolved/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md index 3960dae99..f9af83eb2 100644 --- a/.abcd/work/issues/open/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md +++ b/.abcd/work/issues/resolved/iss-2609251842111403-no-test-opens-the-admission-ordering-gate-through-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "evals/coldreading_rehearsal_test.go" +resolution: "TestTheComparativeAssemblyFollowsTheCommittedWideningIngest now asserts the ordering gate refuses a widening decline before the comparative ingest and admits it after, through the run record the real ingest wrote." +impact: internal +resolved_by: + commit: "b708661e8c905a77539e8e6c546fb5015e5cade2" --- No test opens the admission ordering gate through the comparative channel's real writer: the rehearsal hand-plants the marker (evals/coldreading_rehearsal_test.go:1397), and the comparative eval ingests but never dispositions, so a writer that stops populating candidate_run would leave every such run un-answerable with every gate green (review-admission 3). + +## Grounds + +- pursued: a comparative ingest that stops writing candidate_run now fails the eval; that mutation passing the eval would show it wrong diff --git a/.abcd/work/issues/open/iss-2609252004013212-embark-s-openwalkdir-refusal-returns-the-raw-error-so-the.md b/.abcd/work/issues/resolved/iss-2609252004013212-embark-s-openwalkdir-refusal-returns-the-raw-error-so-the.md similarity index 55% rename from .abcd/work/issues/open/iss-2609252004013212-embark-s-openwalkdir-refusal-returns-the-raw-error-so-the.md rename to .abcd/work/issues/resolved/iss-2609252004013212-embark-s-openwalkdir-refusal-returns-the-raw-error-so-the.md index 3d3913981..3d776fc22 100644 --- a/.abcd/work/issues/open/iss-2609252004013212-embark-s-openwalkdir-refusal-returns-the-raw-error-so-the.md +++ b/.abcd/work/issues/resolved/iss-2609252004013212-embark-s-openwalkdir-refusal-returns-the-raw-error-so-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lifeboat/embark.go" +resolution: "walkLifeboatFilesBounded wraps an openWalkDir failure with walkOpenRefusal, which names the entry by its lifeboat path and keeps only the cause, so the refusal no longer leaks the descent's /. suffix; the probe's walk, the other caller, discards the error." +impact: fix +resolved_by: + commit: "e4cf40ecb" --- embark's openWalkDir refusal returns the raw error, so the fatal line reads 'openat /.: not a directory' and leaks the '/.' suffix into a user-facing refusal naming a path that does not exist (internal/core/lifeboat/embark.go:622; review-lifeboat 2). Wrap it with the entry name. + +## Grounds + +- pursued: we expect an embark refusal for an unopenable directory to name the lifeboat path and never the /. suffix; a refusal quoting openat and a /. path would show it wrong diff --git a/.abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md b/.abcd/work/issues/resolved/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md similarity index 57% rename from .abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md rename to .abcd/work/issues/resolved/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md index d30648b0f..cdfeea66a 100644 --- a/.abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md +++ b/.abcd/work/issues/resolved/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/source/ledger.go" +resolution: "Fixed: the default hazard registry carries abcd-source-ledger-flip, a blocker on abcd source ledger with --flip (either spelling, --corpus stepped over), whose successor leaves the flip to the person. Residual, stated on the guard page: it matches the program by basename abcd, not go run ./cmd/abcd or a renamed copy (the guard is a mistake filter, adr-42)." +impact: fix +resolved_by: + commit: "5b52293e9" --- abcd source ledger --flip is the person's act under adr-41 gate 2 (a line is flipped to public citation only by the human), but the shell guard's default hazard registry does not name it, so an agent can run it unprompted; ledger.go refuses a confidential or non-citable source mechanically, so the exposure is misattributed provenance, not leakage. Register the command in the guard's default registry once the guard lane's registry edit has landed. + +## Grounds + +- pursued: an agent's abcd source ledger --flip is blocked by the bundled registry; a spelling the tests name that the hook still allows would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md b/.abcd/work/issues/resolved/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md new file mode 100644 index 000000000..e2ae8511c --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609252055533837" +slug: "the-decisions-entry-for-the-sources-refresh-says-the-opt-in" +severity: "minor" +category: "documentation" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/work/DECISIONS.md" +resolution: "A dated entry appended to .abcd/work/DECISIONS.md corrects the 2026-09-25 sources-refresh entry: the scaffolded template takes a provisional stance on two of the ruling's three questions (opt-in by default; fail open on an unusable opt-in), leaving only how a scaffolded hook finds abcd unanswered, and both stances stand until iss-2609250834251447 is ruled. The earlier entry is left as written, since DA002 refuses any removed line. No gate would have caught it: lint-decisions checks the ledger's shape, not an entry's claims." +impact: internal +resolved_by: + commit: "fb923e937" +--- + +The DECISIONS entry for the sources refresh says the opt-in shape decides none of iss-2609250834251447's questions, but the template does take a provisional stance for the pre-commit refresh (opt-in; fail open on an unusable opt-in); the entry should call that half provisional (review2-sources 5). + +## Grounds + +- pursued: the appended entry states what internal/core/ahoy/defaults/pre-commit does (nothing runs without abcd.sourcesBinary; an unusable setting prints one line and the commit proceeds); a template branch that refuses the commit, or runs a binary without the opt-in, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609252117203691-in-the-ship-to-tag-window-a-re-run-of-launch-ship-now.md b/.abcd/work/issues/resolved/iss-2609252117203691-in-the-ship-to-tag-window-a-re-run-of-launch-ship-now.md similarity index 56% rename from .abcd/work/issues/open/iss-2609252117203691-in-the-ship-to-tag-window-a-re-run-of-launch-ship-now.md rename to .abcd/work/issues/resolved/iss-2609252117203691-in-the-ship-to-tag-window-a-re-run-of-launch-ship-now.md index d348f551b..67f5752c7 100644 --- a/.abcd/work/issues/open/iss-2609252117203691-in-the-ship-to-tag-window-a-re-run-of-launch-ship-now.md +++ b/.abcd/work/issues/resolved/iss-2609252117203691-in-the-ship-to-tag-window-a-re-run-of-launch-ship-now.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/launch_deep.go" +resolution: "The --baseline flag belongs to launch --dry-run; launch ship does not take it. runShipIngest now asks changelog.ReleaseInFlight, the in-flight rule extracted from Derive, before the pre-flight, and in the ship-to-tag window skips it so the cut's own release-in-flight refusal (exit 1, nothing written) is what the operator sees." +impact: fix +resolved_by: + commit: "4a37e7e87" --- In the ship-to-tag window a re-run of launch ship now refuses on parity first, and that refusal names --baseline , a flag launch ship does not have; the older 'release vX in flight, tag pending' refusal was the clearer message there (internal/surface/cli/launch_deep.go:136-139; review3-launchdiff note). Reword the clause for the ship path, or run the in-flight check ahead of the precheck in runShipIngest. + +## Grounds + +- pursued: we expect a rendering ship in the ship-to-tag window to refuse as a release in flight without naming --baseline; a ship refusal there naming --baseline would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md b/.abcd/work/issues/resolved/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md new file mode 100644 index 000000000..c87f77fa1 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609260933592838" +slug: "archive-tree-reads-attributes-from-the-index" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-launchkind side note" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/gitutil/archive.go" +resolution: "ArchiveTree reads the archived revision's own .gitattributes: --cached when the index holds exactly the revision's attribute files (any git), --source= when they differ (git 2.40+), and on an older git in that state a named refusal with the remedy rather than the index's answer. TestArchiveTreeReadsAttributesFromTheRevision holds the listing to git archive HEAD with a staged attributes change in both directions; TestArchiveTreeRefusesWhenItCannotReadTheRevisionsAttributes pins the old-git refusal." +impact: fix +resolved_by: + commit: "680834da8" +--- + +gitutil.ArchiveTree reads export attributes with `git check-attr --cached` (the index) while `git archive` reads them from the archived tree, so the two disagree when a .gitattributes change is staged but not committed: the launch listing can include or omit a path the released archive does the opposite with. `check-attr --source=` (git 2.40 or later) reads the same tree git archive does. + +## Grounds + +- pursued: the launch listing equals git archive's tree whatever is staged; a staged .gitattributes change that moves a file in the listing but not in the archive would show it wrong diff --git a/.abcd/work/issues/open/iss-2609261034583909-eight-resolved-records-name-in-their-resolution-notes-a-test.md b/.abcd/work/issues/resolved/iss-2609261034583909-eight-resolved-records-name-in-their-resolution-notes-a-test.md similarity index 65% rename from .abcd/work/issues/open/iss-2609261034583909-eight-resolved-records-name-in-their-resolution-notes-a-test.md rename to .abcd/work/issues/resolved/iss-2609261034583909-eight-resolved-records-name-in-their-resolution-notes-a-test.md index 9afed4e86..999d975ac 100644 --- a/.abcd/work/issues/open/iss-2609261034583909-eight-resolved-records-name-in-their-resolution-notes-a-test.md +++ b/.abcd/work/issues/resolved/iss-2609261034583909-eight-resolved-records-name-in-their-resolution-notes-a-test.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-lintA item 4" origin: researcher-authored production_mode: hand-written found_at: ".abcd/work/issues/resolved" +resolution: "Annotated, not corrected: a resolution note true when written stays as written (RS006 re-reads only records entering a terminal folder, for that reason), and each record whose note names a removed test gains a dated Correction section naming the superseding commit, the successor test and which part of the claim still holds. Six records carry one from this change (iss-184, iss-2608301808193750, iss-2608311632382737, iss-2608311632439831, iss-275, iss-354); iss-2609012039117381 already carried one from 2026-09-09 (2eeaab68a); iss-2609090951291799's mention stays accurate. A ledger-wide sweep found a ninth, iss-2608270559313719, which gains the same section in the commit after this record's resolution. No gate would have caught it, and none should: a terminal record's note is history." +impact: internal +resolved_by: + commit: "99452aba0" --- Eight resolved records name, in their resolution notes, a test no longer in the tree; each was true when written, and RS006 reads only records entering a terminal folder, so none is refused, but a reader following the note finds nothing. Verified at b7646ff1, with the commit that removed each test: iss-184 names TestTokenizeRejectsUnterminatedHeredoc (16d50feb renamed it TestTokenizeFlagsUnterminatedHeredoc and changed the behaviour: an unterminated heredoc is a verdict, not ErrUnparsableCommand, so the note's part (2) is stale too); iss-2608301808193750 names TestIsAbsentValueIsASpellingTestNotANullTest (3eb4b549 replaced the spelling predicate with a class-based one, TestAbsenceIsDecidedByClassNotBySpelling, so the note's 'deliberately NOT widened' is superseded); iss-2608311632382737 names TestPreflightRunsBothEvalLanes (the test is TestPreflightRunsEveryTaggedEvalLane, which derives the lanes from the Makefile; the one clean rename of the eight); iss-2608311632439831 names TestComparativeRefusesToAssemble (3b62c967 made the comparative position assemble from the widening run's items, so the refusal the note describes no longer exists); iss-2609012039117381 names TestBinaryHooksFallBackToAPathBinary and iss-275 names TestGuardShimFallsBackToPATH (c637a734 removed the PATH rung those tests pinned: the shims run only the abcd the machine recorded); iss-354 names TestWorkflowGoVersionsMatchSubstitutions (removed in df377ee6, replaced by TestEverySetupGoResolvesTheToolchainFromGoMod under iss-2609090951291799); iss-2609090951291799 names TestWorkflowGoVersionsMatchSubstitutions only to say it was replaced, which stays accurate. Six of the eight need an amendment note stating what superseded the test and the claim, not a rename, so the set is not a mechanical fix. + +## Grounds + +- pursued: a reader following any of the eight notes to a test that is gone finds, in the same record, the commit that removed it and what pins the behaviour now; a resolved record naming a test absent from the tree with no Correction section saying so would show it wrong diff --git a/.abcd/work/issues/open/iss-2609261423210391-itd-135-ac-6-promises-the-beta-badge-is-absent-at-a-v1.md b/.abcd/work/issues/resolved/iss-2609261423210391-itd-135-ac-6-promises-the-beta-badge-is-absent-at-a-v1.md similarity index 66% rename from .abcd/work/issues/open/iss-2609261423210391-itd-135-ac-6-promises-the-beta-badge-is-absent-at-a-v1.md rename to .abcd/work/issues/resolved/iss-2609261423210391-itd-135-ac-6-promises-the-beta-badge-is-absent-at-a-v1.md index 0fb4e7236..2835e9886 100644 --- a/.abcd/work/issues/open/iss-2609261423210391-itd-135-ac-6-promises-the-beta-badge-is-absent-at-a-v1.md +++ b/.abcd/work/issues/resolved/iss-2609261423210391-itd-135-ac-6-promises-the-beta-badge-is-absent-at-a-v1.md @@ -8,6 +8,14 @@ source: "review-followup" found_during: "autonomous run A resumed 2026-09-25: fidelity audit itd-135" origin: researcher-authored production_mode: hand-written +resolution: "TestBuildAtAV1ReleaseDropsTheBetaBadge builds the fixture at a 1.0.0 release and asserts no Beta badge and no beta footer meta." +impact: internal +resolved_by: + commit: "5f20348c7d5b914be245cdd8d371bc5d673999eb" --- itd-135 ac-6 promises the Beta badge is absent at a v1 release with no copy change, but no test builds the site against a v1 release: internal/core/site/build_test.go asserts the badge at a 0.x release (line 1087) and its absence with no release at all (line 1138), so the v1 half of the criterion rests on isPreOne (internal/core/site/build.go:572) with nothing watching it. Wanted: a build test at a v1.0.0 release asserting no class="beta" span renders. + +## Grounds + +- pursued: the v1 half of itd-135 ac-6 is now watched by a build; a predicate that renders the badge at v1 passing this test would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261536147903-planned-intents-and-disciplines-still-cite-specs-of-the.md b/.abcd/work/issues/resolved/iss-2609261536147903-planned-intents-and-disciplines-still-cite-specs-of-the.md new file mode 100644 index 000000000..abd2f31a3 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261536147903-planned-intents-and-disciplines-still-cite-specs-of-the.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261536147903" +slug: "planned-intents-and-disciplines-still-cite-specs-of-the" +severity: "minor" +category: "drift" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/intents/planned" +resolution: "Each named site was read against the live spec its id collides with, and every one is the predecessor store's: spc-12 (live: disembark grounding; cited as the fidelity reviewer), spc-28 (live: the audit rename; cited as the lifecycle hook), spc-29 (live: lint rename; cited as the Role-2/3 finding owner), spc-31 (live: source ledger; cited as HOLD), spc-33 (live: collision-proof ids; cited as the cleanup sweep), spc-43 (live: worktree name guard; cited as the drainer), spc-52 (live: dangling supersedes; cited as audit_loop_policy tasks), spc-66 (live: ledger assistance; cited as the phase-audit receipt), spc-6 (live: issue capture; cited as the lifecycle-owning spec), spc-8 (live: epic-to-spec terminology; cited as delivering IL002, which no live spec or code carries), spc-17 (live: citations; cited as disembark stubs), and itd-37's illustrative spc-1, spc-3, spc-7. itd-1, itd-24, itd-37, itd-48, itd-50 and itd-53 now carry the '(predecessor store)' qualifier on every such prose citation. The routed_from and bundle frontmatter values are data and are left as they are. No gate reads the qualifier, so none would have caught this. The same pattern reaches about forty further intents (192 sites), captured as iss-2609290448510918 for its own reading." +impact: internal +resolved_by: + commit: "95fc0b106" +--- + +Planned intents and disciplines still cite specs of the retired predecessor store unqualified, and such an id at or below spc-70 resolves to a live spec on another subject (spc-12, for one, is live as the disembark grounding spec): itd-48, itd-50 and itd-53 name spc-12, spc-28, spc-29, spc-31, spc-33, spc-43 and spc-52 for the fidelity reviewer, lifecycle hook and cleanup work (which of these are predecessor ids is itself the reading this record owes); itd-24 names spc-66 as the phase-audit receipt; itd-1 and itd-37 name spc-12 as the manual reviewer. The specs charter (Two spc-N Namespaces) rules that every predecessor citation carries the '(predecessor store)' qualifier; iss-239 applied it to the draft corpus only. Each site needs a reading to tell a predecessor citation from a live one, which is why the sweep was not folded into iss-239. + +## Grounds + +- pursued: no prose citation in the six intents names a colliding spc-N without the qualifier; a census over them printing an unqualified id at or below spc-70 that is not the intent's own would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290033521472-host-payload-refusals-outside-ideate-still-quote-the-refused.md b/.abcd/work/issues/resolved/iss-2609290033521472-host-payload-refusals-outside-ideate-still-quote-the-refused.md new file mode 100644 index 000000000..90ca17384 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290033521472-host-payload-refusals-outside-ideate-still-quote-the-refused.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609290033521472" +slug: "host-payload-refusals-outside-ideate-still-quote-the-refused" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lifeboat/synthesis_review.go" +resolution: "Fixed: lifeboat review describes a refused mode, prompt_version and verdict through termsafe.DescribeRefused, and intent audit's dead-letter result returns its reason through the same redactor as the record's copy. The reading ingest's unredacted echo was split out as iss-2609290043245353 and deferred with its reason. The pre-receipt _type refusal, which quoted the value outside the dead-letter path, describes it through termsafe.DescribeRefused since commit 4ad272505." +impact: fix +resolved_by: + commit: "950b69ca2" +--- + +Host-payload refusals outside ideate still quote the refused value into the error text. Found on the sweep for iss-2609090951295881, which fixed the six ideate sites: lifeboat synthesis_review.go quotes an out-of-set mode, prompt_version and verdict with a bare %q (no redaction, no sanitising beyond Go quoting); intent audit.go quotes an out-of-enum criterion verdict, a malformed criterion id, a _type and a receipt id the same way (its dead-letter RECORD is redacted, the returned error is not). Each field arrives in a host-composed payload beside free text the verb does redact, so a token or home path pasted into a closed-set field is printed verbatim to the terminal and transcript on refusal. The reading ingest routes its quoted values through echo(); whether that redacts or only bounds is unverified. Fix direction: the ideate describeRefused shape (length, never the value), shared rather than copied. Detector: for each payload verb, a closed-set field carrying a sentinel must be refused without the sentinel in the error. + +## Grounds + +- pursued: neither verb returns a refused payload value unredacted; a sentinel in the lifeboat review's closed-set fields, or a planted path in an audit verdict token, that reaches the returned error or reason would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290043245353-the-reading-ingest-s-refusals-echo-payload-chosen-values.md b/.abcd/work/issues/resolved/iss-2609290043245353-the-reading-ingest-s-refusals-echo-payload-chosen-values.md new file mode 100644 index 000000000..7856f78e9 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290043245353-the-reading-ingest-s-refusals-echo-payload-chosen-values.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609290043245353" +slug: "the-reading-ingest-s-refusals-echo-payload-chosen-values" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/reading/ingest.go" +resolution: "Fixed by c79673c94: the reading ingest's envelope refusals (_type, run_id, position, manifest_sha256) and WriteRunArtefact's run id describe a refused value with termsafe.DescribeRefused instead of echoing it, and the decoder's undeclared-field message goes through the canonical scanner (redactRefused), failing closed to a description. Every other echo site was classified and kept on the ground that abcd wrote or validated the value: the hash-proven manifest's fields, a manifest_sha256 already matched to 64 hex, a parsed position, the constant artefact name, and item names already through the payload redactor." +impact: fix +resolved_by: + commit: "c79673c94" +--- + +The reading ingest's refusals echo payload-chosen values unredacted. echo() in internal/core/reading/ingest.go cleans a value (termsafe.CleanProseLine) and caps it at maxEchoedBytes (120), but runs no privacy redaction, and 17 call sites use it: the output's _type, run_id, manifest_sha256 and position among them, beside values the manifest (abcd-written) supplies. A reading output whose closed-set or id field carries a token or a home path is refused with up to 120 bytes of it in the error, which reaches the terminal and the transcript. Found on the sweep for iss-2609290033521472, which fixed the same shape in lifeboat review and intent audit with termsafe.DescribeRefused and the audit redactor. Fix direction: per site, describe a payload value (DescribeRefused) or route it through the reading's own payloadField redactor (redact.go), leaving manifest-sourced values as they are. Detector: a reading output carrying a sentinel in each quoted field is refused without the sentinel in the error. + +## Grounds + +- pursued: a reading output carrying a marker and a home path in any envelope field or in an undeclared key is refused without either in the error; a refusal that still carries the marker would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290144116254-the-intent-consistency-ingest-s-refusals-echo-payload-chosen.md b/.abcd/work/issues/resolved/iss-2609290144116254-the-intent-consistency-ingest-s-refusals-echo-payload-chosen.md new file mode 100644 index 000000000..62a00be59 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290144116254-the-intent-consistency-ingest-s-refusals-echo-payload-chosen.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609290144116254" +slug: "the-intent-consistency-ingest-s-refusals-echo-payload-chosen" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/consistency.go" +resolution: "Fixed by c79673c94: the consistency ingest describes a refused _type, policy hash, finding class and severity, and an end's path and quote with termsafe.DescribeRefused instead of quoting them through a bare %q or oneLine, and the decoder's undeclared-field message goes through the canonical scanner (redactRefused), failing closed to a description. The finding and end numbers still locate the fault. The request's review_of_commit stays quoted because it comes from the request abcd wrote and is matched to hex." +impact: fix +resolved_by: + commit: "c79673c94" +--- + +The intent consistency ingest's refusals echo payload-chosen values unredacted. validateConsistency in internal/core/intent/consistency.go quotes the findings payload's _type with a bare %q (line 804, the same shape iss-2609290033521472 fixed in the audit ingest), and quotes a hash value (873), a finding's class and severity (938, 941), an end's path (993) and quote (1000) through oneLine, which cleans and caps but runs no privacy redaction. Each refusal returns to the surface, so a token or home path pasted into one of those fields reaches the terminal and the transcript. Found on the sweep for the audit.go _type fix (fix2-drainSec). Fix direction: describe a closed-set value (termsafe.DescribeRefused) and route path and quote through the verdict-prose redactor, deciding per site whether the echoed value is needed to find the fault. Detector: a findings payload carrying a sentinel in each quoted field is refused without the sentinel in the error. + +## Grounds + +- pursued: a findings payload carrying a marker and a home path in any of those fields or in an undeclared key is refused without either in the error; a refusal that still carries the marker would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290218032954-four-more-host-payload-ingests-echo-payload-chosen-values.md b/.abcd/work/issues/resolved/iss-2609290218032954-four-more-host-payload-ingests-echo-payload-chosen-values.md new file mode 100644 index 000000000..468612f9c --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290218032954-four-more-host-payload-ingests-echo-payload-chosen-values.md @@ -0,0 +1,24 @@ +--- +schema_version: 1 +id: "iss-2609290218032954" +slug: "four-more-host-payload-ingests-echo-payload-chosen-values" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/scribe/ingest.go" +resolution: "Fixed by 0a7d57626. Every site the record named was classified and each payload-chosen one fixed: scribe's handles, states and digests are quoted only in their closed shape and described otherwise, its free text is described, and its undeclared keys and strict-decoder messages (output, parked context and manifest) are redacted; release describes an out-of-set section, a malformed record id and an attributed headline name, quotes a stale next_tag only in a tag's shape, and redacts its decoder message and persona finding; ideate and the four lifeboat decoders redact their messages. One redaction definition serves them all: scanner.RedactRefusal, which fails closed to a description on an unavailable or degraded scanner. The sweep also fixed the reading parked-manifest decoder message and captured the memory page ingest (iss-2609290300464268) and the implement lane receipt (iss-2609290300462829)." +impact: fix +resolved_by: + commit: "0a7d57626" +--- + +Four more host-payload ingests echo payload-chosen values into their refusals, cleaned but never redacted: the class iss-2609290043245353 and iss-2609290144116254 fixed in reading and intent consistency. (1) scribe ingest (internal/core/scribe/ingest.go) passes 21 values through its own echo(), which sanitises and caps but does not redact: the output's _type and run (177, 180), an undeclared key (343), a disposition's item id, state and free text (481-516), among them. (2) release changelog ingest (internal/core/release/ingest.go) quotes next_tag (328), an out-of-set section (532) and a malformed record id (560) through termsafe.Sanitize alone, and the section is uncapped. (3) Five strict decoders return encoding/json's message raw, which names an undeclared field by the payload's own key: ideate record.go:248, lifeboat graveyard_lessons.go:83, synthesis_pressrelease.go:157, synthesis_principles.go:217, synthesis_review.go:275 (release ingest.go:478 sanitises the same message). A token or a home path in any of them reaches the terminal and the transcript. Fix direction: per site, describe a closed-shape value (termsafe.DescribeRefused), redact a name the reader needs through the canonical scanner, keep a value abcd wrote. Detector: each payload carrying a marker and a home path in each field is refused without either in the error. + +A sixth decoder site joins the list from the review of lane drainEcho: scribe.go:198, decodeStrict, reached from ingest.go:328, returns encoding/json's undeclared-field message raw for the scribe output, the parked context and the parked manifest, and its duplicate-key refusal names the repeated key the same way. + +## Grounds + +- pursued: a scribe, release, ideate or lifeboat payload carrying a marker and a home path in any refused field or undeclared key is refused without either in the error, still naming the field; a refusal that still carries the marker or the path would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290300462829-the-implement-loop-s-lane-receipt-refusal-returns-the-strict.md b/.abcd/work/issues/resolved/iss-2609290300462829-the-implement-loop-s-lane-receipt-refusal-returns-the-strict.md new file mode 100644 index 000000000..e5ee2a49c --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290300462829-the-implement-loop-s-lane-receipt-refusal-returns-the-strict.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609290300462829" +slug: "the-implement-loop-s-lane-receipt-refusal-returns-the-strict" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/implement/loop/receipt.go" +resolution: "Fixed: every site classified. receipt.go 179 (decoder message) redacted through scanner.RedactRefusal; 113/116 (run, lane, branch) and 201 (a malformed commit name) described; 235 (a path outside the lane's directory) described; 240-246 (a path inside it) named redacted, and a read error reported by its cause alone. Kept: 95 (the operand), 110/135 (integers), 168 (an abcd-built path), 204-216 (a validated 40-hex name, shortened). receipt_test.go:193's pin (unknown field \"verdict\") holds unchanged, since redaction keeps a plain key. state.go 274 stays out of the class: the loop is its only writer." +impact: fix +resolved_by: + commit: "06432db4c" +--- + +The implement loop's lane receipt refusal returns the strict decoder's message raw, the class iss-2609290218032954 fixed in scribe, release, ideate and lifeboat. The receipt is written by the implementer agent, so it is a host payload, and internal/core/implement/loop/receipt.go:179 formats jsonstrict.Decode's error with %v: an undeclared field or a repeated key is named by the receipt's own spelling, and a token or a home path in that key reaches the terminal and the transcript. receipt_test.go:193 pins the raw key (unknown field "verdict"), so the fix updates that pin. Fix direction: pass the message through scanner.RedactRefusal with the repository root, failing closed to a description. The run state (state.go:274) is out of the class: the loop is its only writer. Detector: a receipt carrying an undeclared key with a home path in it is refused without the path in the error, still naming the key. + +## Grounds + +- pursued: a receipt carrying a home path in an undeclared key, the run, lane, branch, a commit name or the report path is refused without the path and still names what is missing; a refusal quoting the receipt would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290300464268-the-memory-page-ingest-echoes-payload-chosen-values-into-its.md b/.abcd/work/issues/resolved/iss-2609290300464268-the-memory-page-ingest-echoes-payload-chosen-values-into-its.md new file mode 100644 index 000000000..c8e7ee14e --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290300464268-the-memory-page-ingest-echoes-payload-chosen-values-into-its.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609290300464268" +slug: "the-memory-page-ingest-echoes-payload-chosen-values-into-its" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/memory/schema.go" +refines: [iss-2609290218032954] +resolution: "Fixed: every site classified. schema.go 154 (class), 170 (ingested_at), 436/439/442 (type, domain, slug) describe the value; 299 quotes a declared class only when it is in the closed enum and describes any other (the derived set passed requireClass and is kept); 425 names the undeclared keys through scanner.RedactRefusal, so ValidateDistilledPage takes the repository root; 491 and 531 describe the assembled filename. Kept: 196/429/397/403 (constant key names), 254/263 (a class already in the enum). Sibling found and fixed: ingest.go 297 named an uncited page by its filename, whose slug may be token-shaped; it names the page by position. The writer's judgeFilename echo is iss-2609290411321963." +impact: fix +resolved_by: + commit: "4fe838324" +--- + +The memory page ingest echoes payload-chosen values into its refusals, the class iss-2609290218032954 fixed in scribe, release, ideate and lifeboat. A DistilledPage arrives host-produced (memory ingest --pages-json, memory ask --page-json), and internal/core/memory/schema.go quotes its values with %v or %q, sanitised at most and never redacted: a source class (154), an ingested_at (170), the declared classes (299), the undeclared keys themselves (425), type, domain and slug (436-442), and the assembled filename (491, 531). A token or a home path in any of them reaches the terminal and the transcript. Fix direction: describe a closed-shape value with termsafe.DescribeRefused, and name an undeclared key through scanner.RedactRefusal. Detector: a page carrying a marker and a home path in each field is refused without either in the error. + +## Grounds + +- pursued: a DistilledPage carrying a marker and a home path in each field is refused without either in the error and still names the field; a refusal quoting a payload value would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290405381338-the-release-cut-writes-a-well-formed-secret-or-the-caller-s.md b/.abcd/work/issues/resolved/iss-2609290405381338-the-release-cut-writes-a-well-formed-secret-or-the-caller-s.md new file mode 100644 index 000000000..9fe1705f4 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290405381338-the-release-cut-writes-a-well-formed-secret-or-the-caller-s.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609290405381338" +slug: "the-release-cut-writes-a-well-formed-secret-or-the-caller-s" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/release/ingest.go" +resolution: "Fixed: the release cut scans the rendered changelog section and release page with the canonical scanner and refuses any hard_fail finding (a token, a key, the caller's own home or identity) with the new reason code privacy, naming the kind and the line, never the matched text; a degraded scanner is a stop. commands/launch.md and the launch brief name the code. The warn-level identity class (a third party's home path, a bare GitHub username) is split into iss-2609290405451613, open for a ruling on the bar." +impact: fix +resolved_by: + commit: "ecd4518ae" +--- + +The release cut writes a well-formed secret or the caller's own identity into public release text. launch ship --changelog-json accepts a host-composed payload whose changelog entries[].text and press_release headline text pass through checkProse (size, structure, CleanProseLine) and the outbound policy (session URL and tool footer only), then writes them to CHANGELOG.md and RELEASE.md unredacted: a GitHub token, an AWS key or the caller's own home path in a line is written to the working tree as public release text, to be committed and published. Found by the security review of lane drainEcho2 (INFO: changelog write path). The launch dry-run scan would refuse the same hard_fail finding in the bundled CHANGELOG.md later, but only after the file is written, and a person reviewing the diff may commit it first. Fix direction: at the cut, scan the rendered changelog section and release page with the canonical scanner and refuse any hard_fail finding with a reason naming the kind and the line, never the matched text; a degraded scanner is a stop, as checkOutbound's is. Detector: a payload carrying a well-formed token in a changelog line or a headline is refused, nothing is written, and the refusal does not carry the token. + +## Grounds + +- pursued: a payload carrying a well-formed token or the caller's home in a changelog line or a headline is refused and writes nothing; a token or home path written to CHANGELOG.md or RELEASE.md by a cut would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609290411321963-the-memory-writer-s-page-filename-refusal-quotes-the-token.md b/.abcd/work/issues/resolved/iss-2609290411321963-the-memory-writer-s-page-filename-refusal-quotes-the-token.md new file mode 100644 index 000000000..3fcd9d4ab --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609290411321963-the-memory-writer-s-page-filename-refusal-quotes-the-token.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609290411321963" +slug: "the-memory-writer-s-page-filename-refusal-quotes-the-token" +severity: "minor" +category: "security" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/memory/redact.go" +resolution: "Fixed: judgeFilename still names the refused page, which is what the operator repairs, but every span the hard_fail findings over filenameJudgeTexts matched is sealed, marked byte by byte in the joined name so overlapping matches leave no raw tail; a match that cannot be located leaves the whole name described. The writer_filename tests pin the sealed name for the plain slug and all three separator spellings and assert the token is absent." +impact: fix +resolved_by: + commit: "47a510f88" +--- + +The memory writer's page-filename refusal quotes the token it refuses. judgeFilename (internal/core/memory/redact.go, called from writer.go at the write boundary) refuses a page whose host-chosen type, domain or slug carries a hard_fail secret, and its message names the whole filename raw: a slug of ghp_ and forty characters is refused with the token in the error, so it reaches the terminal and the transcript, the class iss-2609290218032954 and iss-2609290300464268 closed elsewhere. The echo is deliberate and pinned: TestWriteRefusesASecretShapedFilename and the separator-spelling cases in writer_filename_test.go assert the refusal names the page, because the filename is the write's identity and the operator needs to know which page to repair. scanner.RedactRefusal cannot square the two: an underscore is a word character, so a token joined behind topic_auth_ has no word boundary and is not redacted in the joined name, and a token can straddle the type, domain and slug separators. Found by lane drainEcho3's sweep for iss-2609290300464268. Fix direction: seal, byte for byte in the joined name, every span the hard_fail findings over filenameJudgeTexts matched, and keep the rest of the name. That keeps both requirements, the page named and the token not echoed, so it needs no ruling; describing the whole name would give up the identity the test pins. Detector: a page whose slug, or a spelling across the separators, is a well-formed token is refused, nothing is written, and the refusal names the page with the token sealed. + +## Grounds + +- pursued: a page whose name carries a well-formed token, in the slug or across a separator, is refused naming the page with the token sealed; a refusal carrying the token would show it wrong diff --git a/.abcd/work/issues/resolved/iss-275-guard-shim-tests-inherit-real-path.md b/.abcd/work/issues/resolved/iss-275-guard-shim-tests-inherit-real-path.md index c4e839239..29b3ffa4c 100644 --- a/.abcd/work/issues/resolved/iss-275-guard-shim-tests-inherit-real-path.md +++ b/.abcd/work/issues/resolved/iss-275-guard-shim-tests-inherit-real-path.md @@ -11,4 +11,15 @@ resolution: "runShim pins PATH (system dirs plus a caller-supplied directory) an impact: internal --- -TestGuardShimFailsOpenLoud's runShim inherits the host PATH, so on a machine with a real abcd installed the guard hook's PATH-fallback rung (shipped with the hook resolution ladder) finds it: the binary-absent case genuinely guards the session via the PATH binary, no UNGUARDED warning prints, and the test fails — environmentally, exactly the machines that dogfood the install. CI is unaffected (no abcd on PATH). Same class as iss-249, in the cli shim harness; the hooks_selfprovision_test hookRun helper already pins PATH for this reason and is the pattern to copy. Detector: the test itself on an affected machine; acceptance: go test ./internal/surface/cli/ -run TestGuardShim passes unmodified with an abcd on PATH — including a new case asserting the PATH rung guards the session when the plugin root is empty. \ No newline at end of file +TestGuardShimFailsOpenLoud's runShim inherits the host PATH, so on a machine with a real abcd installed the guard hook's PATH-fallback rung (shipped with the hook resolution ladder) finds it: the binary-absent case genuinely guards the session via the PATH binary, no UNGUARDED warning prints, and the test fails — environmentally, exactly the machines that dogfood the install. CI is unaffected (no abcd on PATH). Same class as iss-249, in the cli shim harness; the hooks_selfprovision_test hookRun helper already pins PATH for this reason and is the pattern to copy. Detector: the test itself on an affected machine; acceptance: go test ./internal/surface/cli/ -run TestGuardShim passes unmodified with an abcd on PATH — including a new case asserting the PATH rung guards the session when the plugin root is empty. + +## Correction, 2026-09-29 + +The resolution was true when it was written, and its second half is not true +at this tip. `c637a734` ("fix: the hook shims run only the abcd this machine +recorded") removed the plain PATH rung that `TestGuardShimFallsBackToPATH` +pinned: the shim runs a PATH binary only when `~/.abcd/path-entry` records it. +The same commit replaced the test with `TestGuardShimFallsBackToAnOwnedPathBinary` +and `TestGuardShimRefusesAnUnrecordedPathBinary` in +`internal/surface/cli/guard_shim_test.go`. The first half, runShim pinning +PATH, still holds. diff --git a/.abcd/work/issues/resolved/iss-354-ci-yml-s-three-go-version-pins-are-coupled-to-nothing-testse.md b/.abcd/work/issues/resolved/iss-354-ci-yml-s-three-go-version-pins-are-coupled-to-nothing-testse.md index c78e45656..5a8c17b9a 100644 --- a/.abcd/work/issues/resolved/iss-354-ci-yml-s-three-go-version-pins-are-coupled-to-nothing-testse.md +++ b/.abcd/work/issues/resolved/iss-354-ci-yml-s-three-go-version-pins-are-coupled-to-nothing-testse.md @@ -17,3 +17,14 @@ ci.yml's three go-version pins are coupled to nothing: TestSelfScaffoldParity ga - `TestSelfScaffoldParity` builds its case table from `ReleaseYMLPath`/`AutoReleaseYMLPath` only (`internal/core/launch/scaffold/scaffold_test.go:31-54`); nothing reads `.github/workflows/ci.yml`, whose three `go-version` pins (`ci.yml:217,355,390`) float free of `AbcdSubstitutions().GoVersion` (`substitutions.go:27`). - The drift is not hypothetical: between d594511 and 8b70d70 (2026-08-19) CI scanned green on 1.25.13 while release binaries built on 1.25.12 with four unpatched stdlib CVEs. iss-289 (resolved) records that incident and its substitutions fix; this residual — no coupling, so the identical divergence recurs silently — survived it. iss-329 (open) wants the 1.26 move and assumes lockstep exists. - Refuter verdict: CONFIRMED substantive. Fix: a test in the scaffold package asserting every `go-version:` in `.github/workflows/*.yml` equals `AbcdSubstitutions().GoVersion`. + +## Correction, 2026-09-29 + +The resolution was true when it was written, and the test it names is not in +the tree. `df377ee6` ("fix: the format gate is pinned everywhere it runs, not +only in ci.yml") removed `TestWorkflowGoVersionsMatchSubstitutions` together +with the literal `go-version:` pins it coupled: every setup-go step reads +`go-version-file: go.mod`, which `TestEverySetupGoResolvesTheToolchainFromGoMod` +in `internal/core/launch/scaffold/goversion_lockstep_test.go` pins +(iss-2609090951291799). The drift this record names cannot recur in that form, +since no workflow carries a Go version of its own. diff --git a/.abcd/work/issues/resolved/iss-94-the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo.md b/.abcd/work/issues/resolved/iss-94-the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo.md new file mode 100644 index 000000000..b77fb61a5 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-94-the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-94" +slug: "the-intent-corpus-still-specifies-the-pre-adr-35-lifeboat-mo" +severity: "minor" +category: "drift" +source: "agent-finding" +found_during: "itd-88-m0" +found_at: ".abcd/development/intents" +resolution: "The nine intents were re-read at 8322cdf65. itd-24 (the one planned) and the drafts itd-8, itd-9, itd-13, itd-15 and itd-19 spelled the retired 'disembark to ' / 'disembark to home' invocation and now read 'disembark pack ', the shape the binary registers. itd-9's acceptance wrote voyage provenance in-tree under .abcd/development/voyage/ and now names the operator-level ~/.abcd/voyage// store adr-35 decision 3 moved it to; itd-10's question about backing up an in-tree .abcd/lifeboat/ is struck as moot, since a lifeboat is written out of tree. itd-22 carried none of the three already, and itd-2 is superseded and keeps its history. The sweep over every non-superseded intent finds no remaining occurrence except itd-88's audit notes, which cite the old spelling as evidence, and itd-23's proposed to-spec-kit sub-verb, which is a different verb. No gate reads intent prose for retired signatures, so none would have caught this." +impact: internal +resolved_by: + commit: "7f66911c1" +--- + +The intent corpus still specifies the pre-adr-35 lifeboat model: itd-2, itd-8, itd-9, itd-10, itd-13, itd-15, itd-19, itd-22 and itd-24 variously use the retired 'disembark to home' signature, the in-tree .abcd/lifeboat/ home, or the in-tree .abcd/development/voyage/ path (itd-9's acceptance writes voyage provenance in-tree — the exact path that would fail abcd's own privacy-hygiene audit rule). The brief, glossary and roadmap were reconciled to adr-35; the intents were deliberately NOT rewritten, because an intent is a proposal with its own lifecycle and silently rewriting nine of them inside an unrelated change is worse than tracking the drift. Each reconciles when it is next planned. + +## Grounds + +- pursued: no live intent spells the pre-adr-35 lifeboat model; a grep of non-superseded intents for 'disembark to ', '.abcd/lifeboat/' or '.abcd/development/voyage' printing a line outside itd-88's evidence would show it wrong diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17e717214..3a6289752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -542,10 +542,18 @@ jobs: ghcr.io/zizmorcore/zizmor@sha256:d1117e5dbd9ee4970644067b534ab6ab50371f3c6f7f4d05446eb603a6e78f48 \ --persona regular /src - # Deterministic gate for the .abcd/work/reviews/ charter (RD001-RD004). Needs - # full history: RD002 (append-only) checks committed history for post-creation - # edits, so fetch-depth: 0 (the default shallow checkout would miss them). - # Stopgap until the RD codes land in internal/core/lint. + # The working-tier ledger gates: the .abcd/work/reviews/ charter (RD001-RD004), + # the issue-resolution gate (RS001-RS006) and the decisions-append gate + # (DA001-DA004), each after the cases that prove it can fail. The design-record + # drift gate, cmd/record-lint, is NOT here: it is a step of the `check` job. + # The job keeps the id `record-lint` because that id is the required status + # check the main-protection ruleset names (.abcd/work/rulesets/ + # main-protection.json); renaming the job alone leaves the merge gate waiting + # on a check that never reports, so a rename lands only together with the + # live ruleset edit (iss-2609251358062952). Needs full history: RD002 + # (append-only) checks committed history for post-creation edits, so + # fetch-depth: 0 (the default shallow checkout would miss them). The RD half + # is a stopgap until the RD codes land in internal/core/lint. record-lint: timeout-minutes: 5 runs-on: ubuntu-latest diff --git a/commands/docs.md b/commands/docs.md index 7fc814990..cf4200e27 100644 --- a/commands/docs.md +++ b/commands/docs.md @@ -27,14 +27,17 @@ baseline. Report from the JSON: - `outcomes` — each URL's `status` (`ok`, `broken`, `blocked`, `preserved`), its `final_url`, and the `detail`. List every `broken` one: those are dead citations the gate will block on. -- `queue` — sources that refuse automated fetchers. Present these as a checklist +- `queue` — sources that refuse automated fetchers, and sources whose redirect + chain leaves https for plaintext http, which the fetcher does not follow. + Present these as a checklist with the `sites` that cite each one, and tell the user to open each link and confirm it. - `dropped` — receipts removed because the docs no longer cite those addresses. A `blocked` source is **not** recorded as broken and gets no invented entry: a 403 says the fetcher may not look, which is a different fact from the citation -being dead. Never suggest editing the baseline by hand to clear one. +being dead, and so is a redirect off https. Never suggest editing the baseline +by hand to clear one. ## `cite confirm` — closing the manual queue diff --git a/commands/guard.md b/commands/guard.md index a1350ebc7..e03a084ec 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -316,6 +316,13 @@ through a variable or a file, or taken from a `ps | grep` chain, is not seen. Every command of a string a shell is handed with such output in its words is read as handed it, so `sh -c 'kill 4242' _ "$(pgrep …)"` is a **block** too. +A verb that is the person's own act is left to the person. `abcd source ledger +--flip N` records that a human chose to cite a source publicly, so an agent +running it is a **block** (`abcd-source-ledger-flip`); recording a line and +listing the ledger are not. The entry names the program `abcd`, so it matches +the binary on `PATH` and the plugin root's by basename, not a `go run` of the +source or a copy under another name. + A recursive delete of the filesystem root or the home directory (`/`, `/*`, `~`, `$HOME`, `${HOME}`, each also with a trailing `/` or `/*`, and the home's dotfiles `~/.*`, `$HOME/.*`, `${HOME}/.*`) is a **block** diff --git a/commands/intent.md b/commands/intent.md index 5a6bdb404..8fca9f09a 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -905,7 +905,10 @@ an issue's one-way `related_intents` is a loose relation and is not reported), `resolved/`), and `retired_field` (a record still carrying a retired back-link key — `/abcd:capture migrate --apply` rewrites it). Report the finding count, each finding's kind and records, and the receipt path the run left under -`.abcd/.work.local/logs/audit/`. It writes to neither store. `--strict` without +`.abcd/.work.local/logs/audit/`. The ledger is per checkout, so the check names +the one it read: a stderr line `abcd intent audit --issue-drift: ledger of + on branch `, or with `--json` a `ledger` member carrying +`checkout` and `branch`. It writes to neither store. `--strict` without `--issue-drift` is refused. ## Consistency: where do two records contradict each other? diff --git a/commands/launch.md b/commands/launch.md index f0eb7ea72..16ce75a2d 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -278,7 +278,9 @@ Then summarise the JSON for the user: the same way — `CHANGELOG.md` dates the release just cut, which has no tag yet — so the preview refuses there too, naming the release as not tagged yet and `--baseline `, which measures against the release before it; - the cut itself diffs before it writes its heading. `parity.entries` lists + the cut itself diffs before it writes its heading, and a second cut in that + window refuses as a release in flight (exit 1) before any pre-flight runs. + `parity.entries` lists every path `added`, `changed` or `removed` with its `digest` and `baseline_digest` (SHA-256); report the counts and the paths. The two stamped manifests are compared with their version keys removed (`parity.normalised`), and against a release asset the @@ -704,6 +706,7 @@ The reason codes: | `quote-not-verbatim` | carries a quote that is not word for word from its intent's press release, with its attribution | | `outbound-policy` | would put a session URL or a tool attribution footer in the page or the changelog | | `persona-registry` | would put on the page words attributed to a persona the registry does not hold | +| `privacy` | would put a secret, a key, or the caller's own home path or identity in the page or the changelog | Then show the user the written heading and the diff, so a human reviews the release record before it is committed. This command never commits, tags, or publishes. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 73f9f79da..a31f66673 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -77,7 +77,9 @@ the binary as this machine's own. That `PATH` rung is narrow on purpose, and it is owned-only. A hook takes an `abcd` from `PATH` only when the lookup yields an absolute path, in a directory -outside the one the session is working in, that is not world-writable, **and** +outside the one the session is working in, where neither that directory nor the +binary it holds (followed through a symlink to the file it names) is +world-writable, **and** `~/.abcd/path-entry` records that exact path as the `abcd` installed on this machine. The [install](#cli) one-liner writes that record, and so does abcd's own install verb — whichever entry it leaves on `PATH`: the copy of the diff --git a/docs/reference/writing-style.md b/docs/reference/writing-style.md index 051c653c7..8162e42ba 100644 --- a/docs/reference/writing-style.md +++ b/docs/reference/writing-style.md @@ -88,3 +88,10 @@ reviewable, never a default. The other machine-enforced rules have no line escape: `links_resolve`, `stray_root_docs` and the `citation_*` rules are satisfied by fixing the link, the file placement, or the citation itself, not by annotating the line. + +`harness_leak` is the one rule with an escape of its own: a deliberately +illustrative line carries `abcd-lint:allow` (the older spelling +`abcd-audit:allow` is honoured too, because the token lives in committed +content), and the rule does not read the `` comment the +banned-token families take. A fenced block is never flagged, so a page showing +the banned shape as an example needs no escape at all. diff --git a/evals/coldreading_rehearsal_test.go b/evals/coldreading_rehearsal_test.go index 1d7c461d1..8d060e524 100644 --- a/evals/coldreading_rehearsal_test.go +++ b/evals/coldreading_rehearsal_test.go @@ -447,12 +447,33 @@ func TestTheComparativeAssemblyFollowsTheCommittedWideningIngest(t *testing.T) { "characterisation": "how a configuration of this shape ordinarily behaves against " + criterion, }) } + // + // The ordering gate is closed until that ingest lands: a widening item is + // not answered while no committed comparative run names its run. It is + // opened below by the run record the comparative channel's own writer + // commits, never by a hand-placed marker, so a writer that stopped carrying + // candidate_run forward would leave every widening run unanswerable and fail + // here (iss-2609251842111403). + const declineGrounds = "the configuration is admissible and this iteration does not take it up" + early, code := runIn(t, f.Root, []string{"HOME=" + f.Home}, "capture", "disposition", ingested.Records[1].ID, + "--state", "declined", "--grounds", declineGrounds) + if code == 0 { + t.Fatalf("a widening item was dispositioned before the comparative run over its run was ingested:\n%s", early) + } + if !strings.Contains(early, widening.RunID) { + t.Errorf("the ordering refusal does not name the widening run it waits on:\n%s", early) + } res := ingestAccepted(t, f, comparative, items) if len(res.Records) != len(rehearsalCriteria) { t.Fatalf("the comparative ingest recorded %d item(s), want one per declared criterion (%d)", len(res.Records), len(rehearsalCriteria)) } requireCommittedRun(t, f, comparative.RunID, res) + answered := disposition(t, f, ingested.Records[1].ID, "--state", "declined", "--grounds", declineGrounds) + if answered.State != "declined" || answered.Position != posWidening { + t.Errorf("after the comparative ingest the decline records state %q at %q, want declined at %s", + answered.State, answered.Position, posWidening) + } } // rehearsalFixtureWithoutPlantedRuns is the rehearsal fixture with the corpus's diff --git a/hooks/hooks.json b/hooks/hooks.json index f421c0326..f11c9f2ab 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -37,7 +37,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -47,7 +47,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook session-end; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this session's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook session-end; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this session's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -57,7 +57,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook subagent-stop; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this sub-agent's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ldL \"$c\" 2>/dev/null)\" in ????????w*) y=\"the binary itself is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook subagent-stop; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this sub-agent's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } diff --git a/internal/actionsexpr/actionsexpr.go b/internal/actionsexpr/actionsexpr.go index 5c02114bc..7d983ab69 100644 --- a/internal/actionsexpr/actionsexpr.go +++ b/internal/actionsexpr/actionsexpr.go @@ -16,6 +16,8 @@ package actionsexpr import ( "fmt" + "math" + "regexp" "strconv" "strings" ) @@ -410,24 +412,115 @@ func Stringify(v any) string { return fmt.Sprint(v) } -// looseEqual is GitHub's `==`: null compares equal to the empty string, to zero -// and to false, and everything else compares by its string rendering, which is -// exact for the string-vs-string comparisons the workflows here make. +// looseEqual is GitHub's `==`, per its documented loose equality: operands of +// the same type compare directly (strings ignoring case); operands of different +// types are both coerced to a number (see toNumber), and NaN equals nothing. +// Objects and arrays are equal only as the same instance, which no value this +// evaluator produces can be, so they compare unequal. func looseEqual(a, b any) bool { - if a == nil || b == nil { - other := a - if a == nil { - other = b + switch x := a.(type) { + case nil: + if b == nil { + return true + } + case bool: + if y, ok := b.(bool); ok { + return x == y + } + case float64: + if y, ok := b.(float64); ok { + return x == y + } + case string: + if y, ok := b.(string); ok { + return strings.EqualFold(x, y) } - return other == nil || !Truthy(other) + default: + return false } - if ab, ok := a.(bool); ok { - return ab == Truthy(b) + return toNumber(a) == toNumber(b) +} + +// runnerDecimal is the string .NET's Double.TryParse accepts under the styles +// the Actions runner passes it (AllowLeadingSign | AllowDecimalPoint | +// AllowExponent, invariant culture): an optional sign, ASCII digits with at +// most one decimal point and at least one digit, and an optional exponent that +// carries at least one digit. +var runnerDecimal = regexp.MustCompile(`^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`) + +// toNumber is GitHub's coercion of a value to a number for a comparison of +// mismatched types: null is 0, true 1 and false 0, a string is parsed as the +// runner parses it (see parseRunnerNumber), and an array or object is NaN. +func toNumber(v any) float64 { + switch x := v.(type) { + case nil: + return 0 + case bool: + if x { + return 1 + } + return 0 + case float64: + return x + case string: + return parseRunnerNumber(x) } - if bb, ok := b.(bool); ok { - return bb == Truthy(a) + return math.NaN() +} + +// parseRunnerNumber follows the Actions runner's ExpressionUtility.ParseNumber +// (actions/runner, src/Sdk/DTExpressions2/Expressions2/Sdk/ExpressionUtility.cs) +// on .NET 8, the runtime the runner targets, rule for rule and in its order: +// +// 1. Trim Unicode white space; a string left empty is 0. +// 2. Double.TryParse: runnerDecimal, where .NET also tolerates trailing NULs +// after the number, reads an out-of-range magnitude as ±Infinity, and +// falls back to "Infinity", "+Infinity" and "-Infinity" ignoring case +// ("NaN" in any spelling is NaN, as is everything unparsed). +// 3. "0x" and one or more hex digits: Int32 with AllowHexSpecifier, so at +// most eight significant digits read as a 32-bit two's-complement integer. +// 4. "0o" and one or more octal digits: Convert.ToInt32(s, 8), the same +// 32-bit two's-complement reading of a value that fits in 32 bits. +// 5. Anything else, a wider hex or octal value included, is NaN. +// +// The prefixes are lower case only and take no sign, as the runner writes +// them. The runner's own ordinal "Infinity" checks follow TryParse, which on +// .NET 8 has already answered those spellings, so they add nothing here. +func parseRunnerNumber(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + if d := strings.TrimRight(s, "\x00"); runnerDecimal.MatchString(d) { + // An out-of-range value parses to ±Inf or 0 with a range error; + // .NET returns the same value and reports success. + n, _ := strconv.ParseFloat(d, 64) + return n + } + switch { + case strings.EqualFold(s, "Infinity"), strings.EqualFold(s, "+Infinity"): + return math.Inf(1) + case strings.EqualFold(s, "-Infinity"): + return math.Inf(-1) + case len(s) > 2 && s[0] == '0' && s[1] == 'x': + return runnerInt32(s[2:], 16) + case len(s) > 2 && s[0] == '0' && s[1] == 'o': + return runnerInt32(s[2:], 8) + } + return math.NaN() +} + +// runnerInt32 reads digits, all of the given base, as the runner's .NET +// integer parse does: a value that fits in 32 bits, reinterpreted as a signed +// 32-bit integer, and NaN for anything wider or any character outside the +// base. ParseUint with an explicit base takes no sign and no underscore, and +// its base-16 digits are the .NET hex set. +func runnerInt32(digits string, base int) float64 { + u, err := strconv.ParseUint(digits, base, 32) + if err != nil { + return math.NaN() } - return Stringify(a) == Stringify(b) + return float64(int32(uint32(u))) } // EvalIf evaluates a job's or a step's `if:` condition the way GitHub decides diff --git a/internal/actionsexpr/actionsexpr_test.go b/internal/actionsexpr/actionsexpr_test.go index 90bbe4d30..c02fab00f 100644 --- a/internal/actionsexpr/actionsexpr_test.go +++ b/internal/actionsexpr/actionsexpr_test.go @@ -1,6 +1,9 @@ package actionsexpr -import "testing" +import ( + "math" + "testing" +) // TestEvalIfReadsTheStatusFunctionsFromTheContext holds the status-check // functions to the run state the caller states, and to failing closed when it @@ -63,3 +66,105 @@ func TestEvalIfRefusesAValueGitHubWouldReadAsAString(t *testing.T) { t.Error("EvalIf accepted an expression followed by text; GitHub reads that as a non-empty string, always true") } } + +// TestLooseEqualFollowsGitHubsCoercionTable holds `==` to GitHub's loose +// equality as the Actions runner evaluates it: operands of different types are +// both coerced to a number (null 0, true 1, false 0, a string parsed the way the +// runner's ExpressionUtility.ParseNumber parses it, and anything else NaN), NaN +// equals nothing, and strings compare ignoring case (iss-2609251616248870). +func TestLooseEqualFollowsGitHubsCoercionTable(t *testing.T) { + ctx := map[string]any{ + "success()": true, + // The expression grammar has no unary minus and no Infinity literal, + // so the numbers a literal cannot spell come from the context. + "n.minus_one": -1.0, + "n.minus_half": -0.5, + "n.minus_16": -16.0, + "n.two_pow_32": 4294967296.0, + "n.inf": math.Inf(1), + "n.minus_inf": math.Inf(-1), + } + for _, tc := range []struct { + expr string + want bool + }{ + {"true == 'true'", false}, // 1 vs NaN + {"false == 'false'", false}, + {"true == '1'", true}, + {"false == '0'", true}, + {"false == ''", true}, + {"true == 1", true}, + {"false == 0", true}, + {"true == 2", false}, + {"null == ''", true}, + {"null == 0", true}, + {"null == false", true}, + {"null == '0'", true}, + {"null == 'a'", false}, + {"null == null", true}, + {"1 == '1'", true}, + {"1 == '1.0'", true}, + {"1 == '1e0'", true}, + {"0 == ''", true}, + {"1 == 'abc'", false}, + {"1 == '+1'", true}, // the runner allows a leading sign + // The runner's ParseNumber (actions/runner, ExpressionUtility.cs) + // trims whitespace first; a string of only whitespace is 0. + {"1 == ' 1 '", true}, + {"1 == '\t1\n'", true}, + {"0 == ' '", true}, + // A decimal point may lead or trail its digits, but not stand alone. + {"0.5 == '.5'", true}, + {"5 == '5.'", true}, + {"n.minus_half == '-.5'", true}, + {"50 == '5.e1'", true}, + {"0 == '.'", false}, + {"0 == '+'", false}, + {"0 == '-'", false}, + {"1 == '1e'", false}, + {"10 == '1,0'", false}, + {"1000 == '1_000'", false}, + // .NET's double parser accepts trailing NULs after a number. + {"1 == '1\x00\x00'", true}, + {"1 == '1 \x00'", false}, + // 0x hex and 0o octal, lower-case prefix only, parsed as a 32-bit + // two's-complement integer; wider values and a sign are NaN. + {"16 == '0x10'", true}, + {"255 == '0xfF'", true}, + {"1 == '0x000000001'", true}, + {"n.minus_one == '0xffffffff'", true}, + {"n.two_pow_32 == '0x100000000'", false}, + {"16 == '0X10'", false}, + {"n.minus_16 == '-0x10'", false}, + {"0 == '0x'", false}, + {"0 == '0xg'", false}, + {"8 == '0o10'", true}, + {"n.minus_one == '0o37777777777'", true}, + {"n.two_pow_32 == '0o40000000000'", false}, + {"8 == '0O10'", false}, + {"0 == '0o8'", false}, + // Infinity in either sign, and the out-of-range decimal that is one. + {"n.inf == 'Infinity'", true}, + {"n.inf == 'infinity'", true}, + {"n.inf == '+Infinity'", true}, + {"n.minus_inf == '-Infinity'", true}, + {"n.minus_inf == ' -INFINITY '", true}, + {"n.inf == '1e999'", true}, + {"n.inf == 'Inf'", false}, + {"0 == 'NaN'", false}, + {"'abc' == 'ABC'", true}, + {"'abc' != 'abd'", true}, + {"'1' == '1.0'", false}, // same type: compared as strings + {"true == true", true}, + {"1 == 1", true}, + } { + got, err := EvalIf(tc.expr, ctx) + if err != nil { + t.Errorf("EvalIf(%q): %v", tc.expr, err) + continue + } + if got != tc.want { + t.Errorf("EvalIf(%q) = %v, want %v", tc.expr, got, tc.want) + } + } +} diff --git a/internal/adapter/scanner/home_boundary_agreement_test.go b/internal/adapter/scanner/home_boundary_agreement_test.go new file mode 100644 index 000000000..d31bee013 --- /dev/null +++ b/internal/adapter/scanner/home_boundary_agreement_test.go @@ -0,0 +1,32 @@ +package scanner + +import ( + "testing" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// TestHomeRedactorsAgreeOnTheTrailingBoundary holds the two home redactors to +// one trailing rule: for every printable byte after the home, fsutil.RedactRoot +// (the CLI error scrub, the install receipt) redacts exactly when +// SweepCallerHome (the store redactors) sweeps. The two carried the predicate +// in opposite polarities and disagreed on '.', '-' and '_', so "cannot access +// ." was scrubbed by one and left whole by the other +// (iss-2608292037564347). '\\' and '%' are left out: the sweep also reads the +// escape-decoded views of a line carrying them, which fsutil has no +// counterpart for, so they judge a different text rather than a different +// boundary. +func TestHomeRedactorsAgreeOnTheTrailingBoundary(t *testing.T) { + const home = "/srv/qzhome" + for b := byte(0x20); b < 0x7f; b++ { + if b == '\\' || b == '%' { + continue + } + in := "cannot access " + home + string(b) + "tail" + scrubbed := fsutil.RedactRoot(in, home, "~") != in + swept := SweepCallerHome(in, home) != in + if scrubbed != swept { + t.Errorf("byte %q after the home: RedactRoot redacts=%v, SweepCallerHome sweeps=%v", b, scrubbed, swept) + } + } +} diff --git a/internal/adapter/scanner/identity.go b/internal/adapter/scanner/identity.go index 61db63df1..9d9dca3b5 100644 --- a/internal/adapter/scanner/identity.go +++ b/internal/adapter/scanner/identity.go @@ -10,6 +10,7 @@ import ( "unicode" "unicode/utf8" + "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -1403,10 +1404,11 @@ func isDottedIdentifierByte(b byte) bool { } // isPathSegmentByte reports whether b can be part of a path segment, used to -// decide whether a '/' begins an absolute path or continues a nested one. +// decide whether a '/' begins an absolute path or continues a nested one. The +// byte class lives in fsutil (IsPathSegmentByte), the one statement RedactRoot +// reads too (iss-2608292037564347). func isPathSegmentByte(b byte) bool { - return b == '/' || b == '.' || b == '-' || b == '_' || - (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') + return fsutil.IsPathSegmentByte(b) } func boundaryBefore(line string, pos int) bool { diff --git a/internal/adapter/scanner/meter_test.go b/internal/adapter/scanner/meter_test.go index 4fa528050..3fb49b1b5 100644 --- a/internal/adapter/scanner/meter_test.go +++ b/internal/adapter/scanner/meter_test.go @@ -37,6 +37,22 @@ var ( meterWindowsID = Identity{HomePath: `C:\Users\dev`, HomeUser: "dev"} // abcd-audit:allow ) +// overLongSeparatorRun is a backslash run past maxSeparatorRun, written as a +// number rather than derived from the bound, so probing the bound by inflating +// the constant cannot inflate the fixtures with it into gigabyte lines +// (iss-2609251728586400); TestOverLongSeparatorRunOutlastsTheBound keeps it +// past the bound. Probe the unbounded case by removing the bound from +// endsWithPathFold's loop instead. +const overLongSeparatorRun = 128 + +// TestOverLongSeparatorRunOutlastsTheBound keeps the fixtures that exercise the +// separator-run bound past it when the bound moves. +func TestOverLongSeparatorRunOutlastsTheBound(t *testing.T) { + if overLongSeparatorRun <= maxSeparatorRun { + t.Fatalf("overLongSeparatorRun (%d) no longer outlasts maxSeparatorRun (%d): raise it past the bound", overLongSeparatorRun, maxSeparatorRun) + } +} + // meterFixtures are the shapes every stage of a line's scan is held linear on. // Addresses are assembled (network_test.go's v4, v6, mac and host) so the committed // file carries no literal one. @@ -70,7 +86,7 @@ var meterFixtures = []meterFixture{ {"generic_login_commands", meterGenericID, rep("su - dev ")}, {"generic_login_padded_key_values", meterGenericID, rep("USER=" + strings.Repeat(" ", maxKeyGap) + "dev ")}, {"generic_login_escaped_windows_roots", meterWindowsID, rep(`C:\\\\Users\\\\dev\\\\x `)}, // abcd-audit:allow - {"generic_login_after_separator_runs", meterWindowsID, rep(strings.Repeat(`\`, 2*maxSeparatorRun) + "dev ")}, + {"generic_login_after_separator_runs", meterWindowsID, rep(strings.Repeat(`\`, overLongSeparatorRun) + "dev ")}, {"nested_other_homes", Identity{}, rep("/home/a")}, // abcd-audit:allow {"named_login_words", meterNamedID, rep("zq8home ")}, {"named_login_dotted_run", meterNamedID, rep("zq8home.")}, @@ -196,10 +212,23 @@ func TestBoundedContextHelpersKeepTheFinding(t *testing.T) { } // A generic login behind a backslash run longer than the escaping window // reads is reported without the home root the window would have found. + // The second line's run follows no home root at all, so its report is the + // bound's over-report and nothing else: read to its end, the run would + // reach "x", no root, and the login would be spared, as it is behind a run + // inside the window. The first line is a true account position, reported + // with or without the bound (iss-2609251728586400). for _, id := range []Identity{{HomeUser: "dev"}, meterWindowsID} { - long := `C:\Users` + strings.Repeat(`\`, maxSeparatorRun+1) + "dev" + rooted := `C:\Users` + strings.Repeat(`\`, overLongSeparatorRun) + "dev" + if f := ScanText(rooted, id, DefaultPatterns(), DefaultIdentitySeverities(), "f"); !hasKind(f, kindLocalUser) { + t.Errorf("a generic login behind an over-long separator run after a home root was spared (home %q): %+v", id.HomePath, f) + } + long := "x" + strings.Repeat(`\`, overLongSeparatorRun) + "dev" if f := ScanText(long, id, DefaultPatterns(), DefaultIdentitySeverities(), "f"); !hasKind(f, kindLocalUser) { - t.Errorf("a generic login behind an over-long separator run was spared (home %q): %+v", id.HomePath, f) + t.Errorf("a generic login behind an over-long rootless separator run was spared (home %q): %+v", id.HomePath, f) + } + inside := "x" + strings.Repeat(`\`, 4) + "dev" + if f := ScanText(inside, id, DefaultPatterns(), DefaultIdentitySeverities(), "f"); hasKind(f, kindLocalUser) { + t.Errorf("a generic login behind a rootless separator run inside the window was reported (home %q): %+v", id.HomePath, f) } } // Within the bound the exemption still holds. diff --git a/internal/adapter/scanner/refusal.go b/internal/adapter/scanner/refusal.go new file mode 100644 index 000000000..aae412ed1 --- /dev/null +++ b/internal/adapter/scanner/refusal.go @@ -0,0 +1,32 @@ +package scanner + +import "github.com/intentdriven/abcd/internal/termsafe" + +// RedactRefusal renders payload-chosen text for a refusal that is RETURNED to +// the caller rather than written: most often a strict decoder's message, which +// names an undeclared field, or a repeated key, by the payload's own key. That +// name is what the reader needs to find the fault, so it is kept and redacted +// rather than described, and a token or a home path inside it never reaches the +// terminal or the transcript (iss-2609290218032954). +// +// The text goes through the canonical pattern set for repoRoot, then the literal +// sweep of the caller's home (independent of the pattern heuristic, the +// defence-in-depth every store-before-commit redactor applies), then +// termsafe.Sanitize, so the result is inert on a terminal. +// +// It FAILS CLOSED. A returned refusal has no record to note a degradation in, so +// a scanner that cannot be built, or runs degraded, leaves the text DESCRIBED by +// termsafe.DescribeRefused and never echoed. The scanner is built per call: this +// runs on the refusal path alone, so a payload that decodes pays nothing for it. +func RedactRefusal(repoRoot, text string) string { + sc, err := New(repoRoot) + if err != nil { + return termsafe.DescribeRefused(text) + } + if unavail, _ := sc.Unavailable(); unavail { + return termsafe.DescribeRefused(text) + } + out, _ := Redact(text, sc.ScanText(text, "refusal")) + out = SweepCallerHome(out, CallerHome()) + return termsafe.Sanitize(out) +} diff --git a/internal/adapter/scanner/refusal_test.go b/internal/adapter/scanner/refusal_test.go new file mode 100644 index 000000000..134c60021 --- /dev/null +++ b/internal/adapter/scanner/refusal_test.go @@ -0,0 +1,40 @@ +package scanner + +import ( + "path/filepath" + "strings" + "testing" +) + +// TestRedactRefusalKeepsTheNameAndDropsTheLeak: a decoder message naming an +// undeclared key keeps the key's readable part, and loses a planted home path +// and the caller's own home. +func TestRedactRefusalKeepsTheNameAndDropsTheLeak(t *testing.T) { + home := filepath.Join(t.TempDir(), "zzcallerhome") + t.Setenv("HOME", home) + repo := t.TempDir() + msg := `json: unknown field "reviewer_notes /Users/zzotherperson/notes" beside ` + home + "/secret\x1b[31m" // abcd-lint:allow — a planted home path the refusal must not echo + got := RedactRefusal(repo, msg) + for _, leak := range []string{"zzotherperson", "zzcallerhome", "\x1b"} { + if strings.Contains(got, leak) { + t.Errorf("RedactRefusal kept %q: %q", leak, got) + } + } + if !strings.Contains(got, "reviewer_notes") { + t.Errorf("RedactRefusal lost the field name the reader needs: %q", got) + } +} + +// TestRedactRefusalFailsClosed: a scanner that cannot be trusted leaves the text +// described, never echoed. +func TestRedactRefusalFailsClosed(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent") + const msg = `json: unknown field "zzleak-7f3a"` + got := RedactRefusal(missing, msg) + if strings.Contains(got, "zzleak") { + t.Errorf("a degraded scanner echoed the text: %q", got) + } + if !strings.Contains(got, "not quoted") { + t.Errorf("a degraded scanner did not describe the text: %q", got) + } +} diff --git a/internal/adapter/scanner/residual.go b/internal/adapter/scanner/residual.go index edec46135..8f50b7d57 100644 --- a/internal/adapter/scanner/residual.go +++ b/internal/adapter/scanner/residual.go @@ -4,6 +4,8 @@ import ( "os" "sort" "strings" + + "github.com/intentdriven/abcd/internal/fsutil" ) // residual.go — the stage-two discipline the committed stores share. @@ -255,8 +257,11 @@ func atURLPathRoot(at int, urls urlSet) bool { // bare username as a word, where '_' must continue the word so "me" does not // fire inside "me_2"; the home path is a longer literal that carries its own // separators, so a suffix after it is a boundary here. +// +// The rule itself lives in fsutil (NameContinues), so RedactRoot's trailing +// boundary and this one cannot drift apart (iss-2608292037564347). func nameContinues(text string, end int) bool { - return end < len(text) && isAlnumByte(text[end]) + return fsutil.NameContinues(text, end) } func isAlnumByte(b byte) bool { diff --git a/internal/core/banlist/worktree.go b/internal/core/banlist/worktree.go index 1a0f235a5..40cb8805e 100644 --- a/internal/core/banlist/worktree.go +++ b/internal/core/banlist/worktree.go @@ -71,17 +71,21 @@ type InheritedReport struct { // `.git`, never the mirror planted inside it, so the two common dirs differ and // the candidate is refused. // +// Every rev-parse answer is held to one absolute path (gitutil.RevParseAbsPath): +// on a git older than 2.31, which echoes the unknown --path-format option, the +// resolution fails closed, as the guard's does (iss-2608291924452604). +// // It is the Go half of the resolution the committed pre-commit guard makes, and it // is deliberately the same three-way answer: resolution failure is ok=false, never // an error. A read surface that refused to render because it could not find a // SECOND store would be less useful than one that renders the first. func PrimaryWorktreeRoot(repoRoot string) (string, bool) { - gitDir, err := gitutil.Run(repoRoot, "rev-parse", "--path-format=absolute", "--git-dir") - if err != nil || gitDir == "" { + gitDir, err := gitutil.RevParseAbsPath(repoRoot, "--git-dir") + if err != nil { return "", false } - commonDir, err := gitutil.Run(repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir") - if err != nil || commonDir == "" || gitDir == commonDir { + commonDir, err := gitutil.RevParseAbsPath(repoRoot, "--git-common-dir") + if err != nil || gitDir == commonDir { return "", false } wts, err := gitutil.ListWorktrees(repoRoot, maxWorktreeListing) @@ -99,11 +103,11 @@ func PrimaryWorktreeRoot(repoRoot string) (string, bool) { return "", false } // The candidate's own answers, from the candidate's own directory. - top, err := gitutil.Run(primary, "rev-parse", "--path-format=absolute", "--show-toplevel") + top, err := gitutil.RevParseAbsPath(primary, "--show-toplevel") if err != nil || top != primary || !gitutil.ToplevelShaped(primary, top) { return "", false } - common, err := gitutil.Run(primary, "rev-parse", "--path-format=absolute", "--git-common-dir") + common, err := gitutil.RevParseAbsPath(primary, "--git-common-dir") if err != nil || common != commonDir { return "", false } diff --git a/internal/core/banlist/worktree_test.go b/internal/core/banlist/worktree_test.go index 73cbebc68..ba467e2f3 100644 --- a/internal/core/banlist/worktree_test.go +++ b/internal/core/banlist/worktree_test.go @@ -346,3 +346,27 @@ func TestPrimaryWorktreeRootRefusesASeparateGitDirInsideAnotherCheckout(t *testi t.Errorf("an unrelated checkout's private store was reported as inherited: %+v", inh) } } + +// TestPrimaryWorktreeRootRefusesAnAnswerThatIsNotOnePath: git before 2.31 does +// not know --path-format, echoes the option to stdout and exits 0, so a +// rev-parse answer can carry the flag's text as well as a path. Every answer +// the resolution compares must be one absolute path or the resolution fails +// closed, as the pre-commit guard's does (iss-2608291924452604). The git on +// PATH here prefixes the echoed option to its --git-dir and --git-common-dir +// answers only, so the --show-toplevel confirmation alone cannot refuse it. +func TestPrimaryWorktreeRootRefusesAnAnswerThatIsNotOnePath(t *testing.T) { + _, linked := worktreePair(t) + real, err := exec.LookPath("git") + if err != nil { + t.Skip("git unavailable") + } + bin := t.TempDir() + script := "#!/bin/sh\nfor a in \"$@\"; do case \"$a\" in --git-dir|--git-common-dir) echo \"--path-format=absolute\";; esac; done\nexec '" + real + "' \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if got, ok := PrimaryWorktreeRoot(linked); ok { + t.Fatalf("resolved %q from rev-parse answers that are not one absolute path", got) + } +} diff --git a/internal/core/capture/grounds_scope_test.go b/internal/core/capture/grounds_scope_test.go index 16da05a78..3feaa765f 100644 --- a/internal/core/capture/grounds_scope_test.go +++ b/internal/core/capture/grounds_scope_test.go @@ -5,8 +5,6 @@ import ( "strconv" "strings" "testing" - - "github.com/intentdriven/abcd/internal/core/frontmatter" ) // rewriteIssue re-reads an issue file, applies fn to its text and writes it @@ -72,7 +70,10 @@ func openFence(t *testing.T, ir, issID string) { // message alone. It is deliberately body-relative rather than file-relative: the // triage verbs append after setting their note field, so by then the content // carries frontmatter lines the record on disk does not, and a file-relative -// number would name a line the operator's own copy does not have. +// number would name a line the operator's own copy does not have. It is counted +// in the body the ledger's own reader renders, which drops the blank separator +// below the frontmatter; counted in frontmatter.Split's body instead, the +// number named the line below the fence (iss-2608301908288212). func fenceBodyLine(t *testing.T, ir, issID string) int { t.Helper() path, _, err := findIssue(ir, issID) @@ -83,7 +84,10 @@ func fenceBodyLine(t *testing.T, ir, issID string) int { if err != nil { t.Fatal(err) } - _, body := frontmatter.Split(string(data)) + _, body, err := parseFrontmatterAndBody(string(data)) + if err != nil { + t.Fatal(err) + } for i, ln := range strings.Split(body, "\n") { if strings.HasPrefix(ln, "```") { return i + 1 diff --git a/internal/core/capture/serialize.go b/internal/core/capture/serialize.go index 4ae89a1c6..83dde9f71 100644 --- a/internal/core/capture/serialize.go +++ b/internal/core/capture/serialize.go @@ -313,8 +313,10 @@ func setMapField(content, key string, members []kv) (string, error) { // frontmatterBounds locates the leading frontmatter block's opening and closing // delimiter lines in lines (as produced by splitKeepEnds, so ends are kept). The -// open must be the first non-empty line; the close is the next un-indented -// delimiter after it. +// open must be the first line, as every reader requires (parseFrontmatterAndBody, +// frontmatter.Fields, frontmatter.Split): a writer that skipped blank lines to +// find it judged a shape no reader accepts (iss-2608301908288212). The close is +// the next un-indented delimiter after it. // // The two rewrite paths (setScalarField, setMapField) held byte-identical copies // of this scan, and both matched `---` byte-exact — the same divergence from the @@ -324,22 +326,12 @@ func setMapField(content, key string, members []kv) (string, error) { // is the same split verdict pointing the other way. func frontmatterBounds(lines []string) (openIdx, closeIdx int, err error) { openIdx, closeIdx = -1, -1 - for i, ln := range lines { - // A BOM is only a BOM at the file's first line; past it, U+FEFF is an - // ordinary character and must not make a body line a delimiter - // (iss-2608270926036966). Trimming it at i == 0 keeps this writer able to - // rewrite exactly the BOM'd records parseFrontmatterAndBody accepts. - if i == 0 { - ln = frontmatter.TrimBOM(ln) - } - if strings.TrimRight(ln, "\r\n") == "" { - continue - } - if frontmatter.IsDelimiter(ln) { - openIdx = i - break - } - return -1, -1, fmt.Errorf("%w: content has no frontmatter block", ErrMalformedFrontmatter) + // A BOM is only a BOM at the file's first line; past it, U+FEFF is an + // ordinary character and must not make a body line a delimiter + // (iss-2608270926036966). Trimming it here keeps this writer able to + // rewrite exactly the BOM'd records parseFrontmatterAndBody accepts. + if len(lines) > 0 && frontmatter.IsDelimiter(frontmatter.TrimBOM(lines[0])) { + openIdx = 0 } if openIdx == -1 { return -1, -1, fmt.Errorf("%w: content has no frontmatter block", ErrMalformedFrontmatter) diff --git a/internal/core/capture/serialize_test.go b/internal/core/capture/serialize_test.go index cfb9e1df7..10b4aeb43 100644 --- a/internal/core/capture/serialize_test.go +++ b/internal/core/capture/serialize_test.go @@ -119,6 +119,23 @@ func TestParseRejectsMissingOpener(t *testing.T) { } } +// TestWriterAndReaderAgreeTheOpenerIsTheFirstLine: a record whose opener sits +// below a blank line is refused by the reader, and the rewrite paths refuse it +// the same way rather than finding the block the reader never reads +// (iss-2608301908288212). +func TestWriterAndReaderAgreeTheOpenerIsTheFirstLine(t *testing.T) { + text := "\n---\nid: \"iss-1\"\n---\n\nbody\n" + if _, _, err := parseFrontmatterAndBody(text); err == nil { + t.Fatal("the reader accepted an opener below a blank line; this test's premise is gone") + } + if got, err := setScalarField(text, "resolution", "x"); err == nil { + t.Fatalf("the writer rewrote a record the reader refuses:\n%s", got) + } + if _, err := setScalarField(strings.TrimPrefix(text, "\n"), "resolution", "x"); err != nil { + t.Fatalf("the same record with its opener on the first line must be rewritable: %v", err) + } +} + func TestYamlScalarRejectsControlChar(t *testing.T) { if _, err := yamlScalar("bad\nvalue"); err == nil { t.Fatal("expected control-char rejection") diff --git a/internal/core/changelog/derive.go b/internal/core/changelog/derive.go index 64d09e9c2..4ced46e79 100644 --- a/internal/core/changelog/derive.go +++ b/internal/core/changelog/derive.go @@ -98,12 +98,12 @@ func Derive(root string) (Derivation, error) { } d.Base, d.BaseTag = base, base.Tag() - heading, hasHeading, err := LatestChangelogVersion(root) + inFlight, reason, err := releaseInFlight(root, base) if err != nil { return Derivation{}, err } - if hasHeading && launch.CoreGreater(heading, base) { - return refuse(d, RefusalReleaseInFlight, "release "+heading.Tag()+" in flight — tag pending (the newest CHANGELOG heading is ahead of "+d.BaseTag+")"), nil + if inFlight { + return refuse(d, RefusalReleaseInFlight, reason), nil } records, err := ShippedSince(root, d.BaseTag) @@ -141,3 +141,31 @@ func refuse(d Derivation, kind RefusalKind, reason string) Derivation { d.NextTag = "" return d } + +// ReleaseInFlight reports whether a release sits between its cut and its tag — +// the newest CHANGELOG heading ahead of the newest release tag — and the reason +// Derive refuses it with. It reads only the tags and the CHANGELOG, so a caller +// can ask it before work that would otherwise refuse first on a symptom of the +// same window: the ship's payload parity diff, whose baseline advice names a flag +// the ship does not take (iss-2609252117203691). No tag is not in flight; Derive +// refuses that case on its own. +func ReleaseInFlight(root string) (bool, string, error) { + base, hasTag, err := LatestReleaseTag(root) + if err != nil || !hasTag { + return false, "", err + } + return releaseInFlight(root, base) +} + +// releaseInFlight is the one statement of the in-flight rule, against base, the +// newest release tag. +func releaseInFlight(root string, base launch.Semver) (bool, string, error) { + heading, hasHeading, err := LatestChangelogVersion(root) + if err != nil { + return false, "", err + } + if hasHeading && launch.CoreGreater(heading, base) { + return true, "release " + heading.Tag() + " in flight — tag pending (the newest CHANGELOG heading is ahead of " + base.Tag() + ")", nil + } + return false, "", nil +} diff --git a/internal/core/cite/fetch.go b/internal/core/cite/fetch.go index 1674a2fc4..8fdb07282 100644 --- a/internal/core/cite/fetch.go +++ b/internal/core/cite/fetch.go @@ -150,6 +150,19 @@ func newHTTPChecker(blocked func(net.IP) bool, timeout time.Duration) *HTTPCheck if len(via) >= maxRedirects { return &redirectRefusal{errors.New("more than " + strconv.Itoa(maxRedirects) + " redirects")} } + // A chain that has reached https never leaves it. The hop's + // scheme is the redirecting host's choice, so without this pin + // an https citation walked down to plaintext is followed, and + // whatever answers on the plaintext leg — which anyone on the + // path can forge — supplies the final address the baseline + // records (iss-2609012037440084; memory ingest and update pin + // the same way). It stands ahead of the host guard so the + // refusal names the scheme. An http citation, and an http hop + // before any https one, are followed as before: the pin is + // against a downgrade, not against http. + if r.URL.Scheme != "https" && reachedHTTPS(via) { + return &redirectRefusal{errHTTPSDowngrade} + } // Every hop is re-guarded: a public address that redirects to // 169.254.169.254 must not be followed. if err := urlguard.CheckHostWith(r.URL.Hostname(), blocked); err != nil { @@ -186,6 +199,16 @@ func (c *HTTPChecker) Check(rawURL string) CheckOutcome { req.Header.Set("Accept", "text/html,application/xhtml+xml,*/*;q=0.8") resp, err := c.client.Do(req) + if errors.Is(err, errHTTPSDowngrade) { + // Not evidence the source is dead — the host answered, and pointed + // somewhere this checker will not follow — so it goes to the manual + // queue, never into the baseline as broken. The detail names the + // scheme only: the plaintext hop's URL is redirect-controlled and may + // carry a credential in its query. + out.Status, out.Answered = StatusBlocked, true + out.Detail = "the redirect chain left https for plaintext http, which is not followed — confirm the source by hand" + return out + } if err != nil { out.Status, out.Detail = StatusBroken, transportDetail(err) // A refused redirect chain is still a chain: a host answered 3xx to get @@ -236,6 +259,21 @@ func classify(code int) (Status, string) { } } +// errHTTPSDowngrade is the redirect policy's refusal of a hop that leaves +// https once the chain has reached it. +var errHTTPSDowngrade = errors.New("a redirect left https for plaintext http") + +// reachedHTTPS reports whether any request already made in the chain was +// https, so the next hop may not be anything else. +func reachedHTTPS(via []*http.Request) bool { + for _, r := range via { + if r.URL != nil && r.URL.Scheme == "https" { + return true + } + } + return false +} + // redirectRefusal marks an error raised from CheckRedirect — one where a host // DID answer, with a 3xx, before abcd declined to follow it. It exists so Check // can set Answered on a path where client.Do reports only an error. diff --git a/internal/core/cite/fetch_downgrade_test.go b/internal/core/cite/fetch_downgrade_test.go new file mode 100644 index 000000000..1d3b31223 --- /dev/null +++ b/internal/core/cite/fetch_downgrade_test.go @@ -0,0 +1,93 @@ +package cite + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// tlsChecker is testChecker trusting srv's self-signed certificate, so a chain +// that starts on https can be followed end to end against loopback servers. +func tlsChecker(t *testing.T, srv *httptest.Server) *HTTPChecker { + t.Helper() + c := testChecker(5 * time.Second) + tr, ok := c.client.Transport.(*http.Transport) + if !ok { + t.Fatalf("the checker's transport is %T, not *http.Transport", c.client.Transport) + } + tr.TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone() + return c +} + +// TestCheckDoesNotFollowARedirectOffHTTPS: the fetcher re-guarded the host on +// every hop but pinned no scheme, so an https citation redirected to plaintext +// http was followed, and the address the plaintext leg reported reached the +// committed baseline as the citation's final URL (iss-2609012037440084, the +// shape GHSA-35fj-9w6f-7h62 closed in memory ingest). A downgrade is refused, +// and — since it is no evidence the source is dead — routed to the manual +// queue as blocked rather than recorded as broken. +func TestCheckDoesNotFollowARedirectOffHTTPS(t *testing.T) { + plainHit := false + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + plainHit = true + w.WriteHeader(http.StatusOK) + })) + defer plain.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL+"/landed?token=SECRET-QUERY-VALUE", http.StatusFound) + })) + defer secure.Close() + + got := tlsChecker(t, secure).Check(secure.URL + "/cited") + if plainHit { + t.Fatal("the checker followed an https citation down to plaintext http") + } + if got.Status != StatusBlocked { + t.Fatalf("status = %q (%s), want %q: a downgrade is not evidence the source is dead", got.Status, got.Detail, StatusBlocked) + } + if !got.Answered { + t.Error("a host answered with a 3xx, so the outcome must say so") + } + if got.FinalURL != "" { + t.Errorf("final URL = %q, want none: the plaintext address must not reach the baseline", got.FinalURL) + } + if strings.Contains(got.Detail, "SECRET-QUERY-VALUE") || strings.Contains(got.Detail, plain.URL) { + t.Errorf("the refusal echoes the plaintext hop: %q", got.Detail) + } + if !strings.Contains(got.Detail, "https") { + t.Errorf("the refusal does not say the chain left https: %q", got.Detail) + } +} + +// TestCheckStillFollowsAnHTTPCitationUpgrade: the pin is against LEAVING https, +// not against http. A citation written as http:// that upgrades to https — the +// commonest redirect there is — and an http chain that never reached https are +// followed exactly as before; refusing them would record a live source as +// unverifiable. +func TestCheckStillFollowsAnHTTPCitationUpgrade(t *testing.T) { + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer secure.Close() + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/upgrade": + http.Redirect(w, r, secure.URL+"/page", http.StatusMovedPermanently) + case "/moved": + http.Redirect(w, r, "/page", http.StatusMovedPermanently) + default: + w.WriteHeader(http.StatusOK) + } + })) + defer plain.Close() + + c := tlsChecker(t, secure) + if got := c.Check(plain.URL + "/upgrade"); got.Status != StatusOK || got.FinalURL != secure.URL+"/page" { + t.Errorf("http->https upgrade: status %q final %q (%s), want ok at %s", got.Status, got.FinalURL, got.Detail, secure.URL+"/page") + } + if got := c.Check(plain.URL + "/moved"); got.Status != StatusOK || got.FinalURL != plain.URL+"/page" { + t.Errorf("http->http move: status %q final %q (%s), want ok at %s", got.Status, got.FinalURL, got.Detail, plain.URL+"/page") + } +} diff --git a/internal/core/decide/ancestor_symlink_test.go b/internal/core/decide/ancestor_symlink_test.go new file mode 100644 index 000000000..c7d089cff --- /dev/null +++ b/internal/core/decide/ancestor_symlink_test.go @@ -0,0 +1,33 @@ +package decide + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCreateRefusesASymlinkedAncestor: the mint lock refused a symlinked +// LEAF and then called os.MkdirAll, which follows a symlinked ANCESTOR, so a +// committed `.abcd/development -> ` put the ADR outside the +// checkout. Found on the sweep for iss-2609012037137250: the same create +// sequence the spec store carried. +func TestCreateRefusesASymlinkedAncestor(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, ".abcd", "development")); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + if d, err := Create(root, "A decision minted through a planted link"); err == nil { + t.Fatalf("Create minted %s through a symlinked .abcd/development", d.Path) + } + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("the refused mint still created %d entr(y|ies) under the symlink's target", len(entries)) + } +} diff --git a/internal/core/decide/decide.go b/internal/core/decide/decide.go index b6c881062..793d9b9b4 100644 --- a/internal/core/decide/decide.go +++ b/internal/core/decide/decide.go @@ -266,10 +266,10 @@ func redactDecisionText(repoRoot, text string) (string, error) { // O_NOFOLLOW refuses a symlinked store. func withMintLock(repoRoot string, fn func() error) error { dir := filepath.Join(repoRoot, filepath.FromSlash(ADRsRelDir)) - if di, err := os.Lstat(dir); err == nil && di.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("decide: %s is a symlink (refusing to follow)", ADRsRelDir) - } - if err := os.MkdirAll(dir, 0o755); err != nil { + // Every level is created and proved real, ancestors included: a leaf + // Lstat followed by os.MkdirAll refuses a symlinked adrs/ but follows a + // symlinked .abcd/development and mints the ADR under its target. + if err := fsutil.EnsureRealDirAll(repoRoot, ADRsRelDir, 0o755); err != nil { return fmt.Errorf("decide: creating %s: %w", ADRsRelDir, err) } fd, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) diff --git a/internal/core/glossary/guarded_read_test.go b/internal/core/glossary/guarded_read_test.go new file mode 100644 index 000000000..b36e529af --- /dev/null +++ b/internal/core/glossary/guarded_read_test.go @@ -0,0 +1,86 @@ +package glossary + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// The glossary store is committed and travels with a clone, and the index walk +// reads every term file it finds. A bare os.ReadFile there hangs every verb that +// builds the index on a committed FIFO, and reads an out-of-tree file as a term +// through a committed symlink (iss-2609012037125129, the sibling of +// GHSA-fh9j-8xmg-m33f). The term read goes through fsutil.ReadGuardedInRoot with +// a term-family cap (maxTermBytes) since 746a5d2c; these pin that it stays so. + +// termStore lays out one bounded context holding a valid term, and returns the +// repository root and the context directory. +func termStore(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + ctx := filepath.Join(root, filepath.FromSlash(DirRelPath), "core") + if err := os.MkdirAll(ctx, 0o755); err != nil { + t.Fatal(err) + } + valid := "---\nterm: widget\nstatus: settled\ndefinition: a thing\n---\n" + if err := os.WriteFile(filepath.Join(ctx, "widget.md"), []byte(valid), 0o644); err != nil { + t.Fatal(err) + } + return root, ctx +} + +// scanWithin runs Scan with a deadline, so a read that blocks fails the test +// instead of hanging the suite. +func scanWithin(t *testing.T, root string) error { + t.Helper() + done := make(chan error, 1) + go func() { + _, err := Scan(root) + done <- err + }() + select { + case err := <-done: + return err + case <-time.After(10 * time.Second): + t.Fatal("Scan blocked on a term file: the read is not O_NONBLOCK-guarded") + return nil + } +} + +func TestScanRefusesAFIFOTermFile(t *testing.T) { + root, ctx := termStore(t) + if err := syscall.Mkfifo(filepath.Join(ctx, "fifo.md"), 0o644); err != nil { + t.Skipf("mkfifo unsupported: %v", err) + } + if err := scanWithin(t, root); err == nil { + t.Fatal("Scan read a FIFO as a term file") + } +} + +func TestScanRefusesASymlinkedTermFile(t *testing.T) { + root, ctx := termStore(t) + outside := filepath.Join(t.TempDir(), "secret.md") + if err := os.WriteFile(outside, []byte("---\nterm: leaked\nstatus: settled\ndefinition: out of tree\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(ctx, "leaked.md")); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + if err := scanWithin(t, root); err == nil { + t.Fatal("Scan read an out-of-tree file through a symlinked term file") + } +} + +func TestScanRefusesAnOversizedTermFile(t *testing.T) { + root, ctx := termStore(t) + big := make([]byte, maxTermBytes+1) + copy(big, "---\nterm: huge\nstatus: settled\ndefinition: x\n---\n") + if err := os.WriteFile(filepath.Join(ctx, "huge.md"), big, 0o644); err != nil { + t.Fatal(err) + } + if err := scanWithin(t, root); err == nil { + t.Fatal("Scan read a term file past the term-family cap") + } +} diff --git a/internal/core/grounds/record.go b/internal/core/grounds/record.go index b03f3cec7..989884d3f 100644 --- a/internal/core/grounds/record.go +++ b/internal/core/grounds/record.go @@ -25,6 +25,7 @@ import ( "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/mdrecord" + "github.com/intentdriven/abcd/internal/core/mdrender" ) // Body returns the part of a record FILE the `## Grounds` section lives in — @@ -37,8 +38,11 @@ import ( // skips and an ATX heading pattern matches. Writer and reader could then agree // an entry had landed while disagreeing about where (iss-2608301805069999). // -// A text that carries no frontmatter is already a body and comes back unchanged, -// which is what lets a caller holding either shape ask. +// Hand it the record FILE. A text with no opening delimiter comes back whole, +// but that is not a licence to pass a bare body: a body that opens with a +// thematic break (`---`) reads as a frontmatter opener, and everything down to +// the next such line is taken as frontmatter and dropped, the section with it +// (iss-2608301908288212). func Body(content string) string { _, body := frontmatter.Split(content) return body @@ -226,10 +230,7 @@ func AppendToRecord(content string, g Grounds) (string, error) { // report has nowhere to raise this, so the refusal lives at the write. bodyLines := strings.Split(body, "\n") if n := mdrecord.CountHeadings(bodyLines, mdrecord.Mask(bodyLines), headingRe); n > 1 { - return "", fmt.Errorf( - "the record's body carries %d live `## %s` headings and the reader takes the first, so "+ - "entries under the others are invisible to every surface; merge them into one section "+ - "before recording another; nothing written", n, Heading) + return "", ambiguousRefusal(body, n) } updated := appendBullet(body, g) if want, got := len(ParseSection(body))+1, len(ParseSection(updated)); got != want { @@ -258,8 +259,8 @@ func AppendToRecord(content string, g Grounds) (string, error) { // the hand edit the 2026-08-31 ruling accepted: close or remove the opener in a // text editor, then re-run the verb (iss-2608301908270888). // -// The line is counted from the start of the BODY, and the opener's own text is -// quoted beside it. A file-relative number would be wrong: the triage verbs +// The line is counted from the start of the BODY as a reader renders it +// (bodyLine), and the opener's own text is quoted beside it. A file-relative number would be wrong: the triage verbs // append after setting their note field, so the content in hand carries // frontmatter lines the record on disk — the one the operator opens, since // nothing is written — does not. The body is what those writes leave alone, so a @@ -273,13 +274,53 @@ func readBackRefusal(body string, got, want int) error { "to end of file, so every line below it — the appended entry included — is masked and "+ "does not read back. Close it or remove it in a text editor, then re-run; the grounds text is "+ "not the fault; nothing written", - maskConstruct(flag), i+1, strings.TrimRight(lines[i], "\r")) + maskConstruct(flag), bodyLine(body, i), quotedLine(lines[i])) } return fmt.Errorf( "the appended grounds entry does not read back (%d entries after the append, expected %d); "+ "nothing written", got, want) } +// ambiguousRefusal explains a body carrying more than one live Grounds heading, +// to the standard readBackRefusal sets: each heading by its body line and its +// own text, so the operator can find both from the message alone. The heading +// is named without a depth because the pattern matches every depth, and a +// record whose second heading is `### Grounds` must not be told it has two +// `## Grounds` (iss-2608301908288212). +func ambiguousRefusal(body string, n int) error { + lines := strings.Split(body, "\n") + mask := mdrecord.Mask(lines) + var where []string + for i, ln := range lines { + if (i >= len(mask) || mask[i] == 0) && headingRe.MatchString(strings.TrimRight(ln, "\r")) { + where = append(where, fmt.Sprintf("body line %d, %q", bodyLine(body, i), quotedLine(ln))) + } + } + return fmt.Errorf( + "the record's body carries %d live %s headings (%s) and the reader takes the first, so "+ + "entries under the others are invisible to every surface; merge them into one section "+ + "before recording another; nothing written", n, Heading, strings.Join(where, "; ")) +} + +// bodyLine is the 1-based number a refusal gives line i of body, counted in +// the body a record reader renders. Split's body starts with the line ending +// the blank separator below the closing delimiter leaves, and the readers strip +// exactly that one, so counting from Split's first line would name the line +// below the one meant (iss-2608301908288212). +func bodyLine(body string, i int) int { + if strings.HasPrefix(body, "\n") || strings.HasPrefix(body, "\r\n") { + return i + } + return i + 1 +} + +// quotedLine is a body line as a refusal quotes it: its carriage return +// dropped and its length clipped, since a record read without a byte cap can +// carry a line of any length (iss-2608301908288212). +func quotedLine(ln string) string { + return mdrender.Clip(strings.TrimRight(ln, "\r")) +} + // maskConstruct names a mask flag the way the record spells it, so the operator // searches the file for the thing the message named. func maskConstruct(flag uint8) string { @@ -300,6 +341,12 @@ func appendBullet(content string, g Grounds) string { start, end, ok := mdrecord.SectionLineRangeIn(lines, mask, headingRe) if !ok { body := strings.TrimRight(content, "\n") + if body == "" { + // A frontmatter-only record: the file already ends on the closing + // delimiter's line, so one blank line separates the section, as + // it does below any prose (iss-2608301908288212). + return "\n## " + Heading + "\n\n" + g.Bullet() + "\n" + } return body + "\n\n## " + Heading + "\n\n" + g.Bullet() + "\n" } section := append([]string{}, lines[start:end]...) diff --git a/internal/core/grounds/record_test.go b/internal/core/grounds/record_test.go new file mode 100644 index 000000000..9a0b6b8ba --- /dev/null +++ b/internal/core/grounds/record_test.go @@ -0,0 +1,71 @@ +package grounds + +import ( + "strings" + "testing" +) + +func mustGrounds(t *testing.T) Grounds { + t.Helper() + g, err := New(Pursued, conjecture) + if err != nil { + t.Fatal(err) + } + return g +} + +// Two live Grounds headings are refused, each named by its body line and its +// own text, and without claiming a depth the second one does not have: the +// pattern matches every depth, so a `### Grounds` is one of the two +// (iss-2608301908288212). +func TestAmbiguousRefusalNamesEachHeadingWhereItIs(t *testing.T) { + file := "---\nid: x\n---\n\n# T\n\n## Grounds\n\n- pursued: " + conjecture + "\n\n### Grounds\n\n- deferred: " + conjecture + "\n" + _, err := AppendToRecord(file, mustGrounds(t)) + if err == nil { + t.Fatal("a body with two live Grounds headings was appended to") + } + msg := err.Error() + if strings.Contains(msg, "`## Grounds`") { + t.Errorf("the refusal names a `## Grounds` depth the second heading does not have: %s", msg) + } + // Counted in the body a reader renders: "# T" is line 1. + for _, want := range []string{`body line 3, "## Grounds"`, `body line 7, "### Grounds"`} { + if !strings.Contains(msg, want) { + t.Errorf("the refusal does not locate %s: %s", want, msg) + } + } +} + +// The unclosed opener is numbered in the body a reader renders, which starts +// below the blank separator, and quoted clipped, since a record can carry a +// line of any length (iss-2608301908288212). +func TestReadBackRefusalNumbersAndClipsTheOpener(t *testing.T) { + opener := "```" + strings.Repeat("x", 5000) + file := "---\nid: x\n---\n\n# T\n\n" + opener + "\n" + _, err := AppendToRecord(file, mustGrounds(t)) + if err == nil { + t.Fatal("an append below an unclosed fence read back") + } + msg := err.Error() + if !strings.Contains(msg, "body line 3,") { + t.Errorf("the opener is line 3 of the rendered body: %s", msg) + } + if strings.Contains(msg, strings.Repeat("x", 100)) || len(msg) > 1000 { + t.Errorf("the refusal quotes the opener unclipped (%d bytes)", len(msg)) + } +} + +// A record with frontmatter and no body gains the section one blank line below +// the closing delimiter, as a record with prose gains it below the prose +// (iss-2608301908288212). +func TestAppendToAFrontmatterOnlyRecordLeavesOneBlankLine(t *testing.T) { + g := mustGrounds(t) + got, err := AppendToRecord("---\nid: x\n---\n", g) + if err != nil { + t.Fatal(err) + } + want := "---\nid: x\n---\n\n## Grounds\n\n" + g.Bullet() + "\n" + if got != want { + t.Errorf("AppendToRecord = %q, want %q", got, want) + } +} diff --git a/internal/core/guard/defaults/guard.json b/internal/core/guard/defaults/guard.json index c79500741..021b250ca 100644 --- a/internal/core/guard/defaults/guard.json +++ b/internal/core/guard/defaults/guard.json @@ -491,6 +491,30 @@ "abcd capture \"killall -u bob stopped every session\"" ] } + }, + "abcd-source-ledger-flip": { + "tier": "blocker", + "pattern": { + "command": "abcd", + "subcommand": "source", + "subcommand2": "ledger", + "value_flags": ["--corpus"], + "flags": ["--flip"] + }, + "why": "Flipping a source-ledger line to public citation is the person's own act: the ledger records that a human chose to cite that source, so a flip an agent makes writes a false record of who decided.", + "successor": "Leave this one to the person: tell them which ledger line you believe is ready to cite publicly and why, and let them run `abcd source ledger --flip N` themselves.", + "fixtures": { + "known_bad": [ + "abcd source ledger --flip 3", + "abcd source ledger --flip=3", + "abcd source --corpus /srv/corpus ledger --flip 3" + ], + "known_good": [ + "abcd source ledger --list", + "abcd source ledger --decision adr-7 --claim c --source k --influence i", + "abcd capture \"an agent ran abcd source ledger --flip 3\"" + ] + } } } } diff --git a/internal/core/guard/sourceflip_test.go b/internal/core/guard/sourceflip_test.go new file mode 100644 index 000000000..462f49ef5 --- /dev/null +++ b/internal/core/guard/sourceflip_test.go @@ -0,0 +1,25 @@ +package guard + +import "testing" + +// TestSourceLedgerFlipIsLeftToThePerson — iss-2609252007448074. Flipping a +// source-ledger line to public citation is the person's act under adr-41 gate +// 2, and the plugin pages tell an agent never to run it, but nothing stopped +// one: the default registry did not name it. ledger.go still refuses a +// confidential or non-citable source mechanically, so what the entry guards is +// provenance — a line that says a human cited it when a model did. Recording a +// line, listing the ledger, and every other source verb stay allowed. +func TestSourceLedgerFlipIsLeftToThePerson(t *testing.T) { + runVerdictCases(t, []verdictCase{ + {`abcd source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd source ledger --flip=3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd --json source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd source --corpus /srv/corpus ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`"$CLAUDE_PLUGIN_ROOT"/abcd source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`sh -c 'abcd source ledger --flip 3'`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd source ledger --list`, VerdictAllow, ""}, + {`abcd source ledger --decision adr-7 --claim c --source k --influence i`, VerdictAllow, ""}, + {`abcd source cite-check README.md`, VerdictAllow, ""}, + {`abcd capture "an agent ran abcd source ledger --flip 3"`, VerdictAllow, ""}, + }) +} diff --git a/internal/core/history/home_fold_test.go b/internal/core/history/home_fold_test.go new file mode 100644 index 000000000..e838d2a7b --- /dev/null +++ b/internal/core/history/home_fold_test.go @@ -0,0 +1,33 @@ +//go:build unix + +package history + +import ( + "path/filepath" + "testing" +) + +// TestLocalTranscriptRootsCaseVariantFollowsTheFoldPredicate: a +// local-transcript-roots entry spelled in a case variant of the checkout pulls +// it in exactly when the filesystem folds case. The branch cannot be provoked +// on a case-sensitive host, so the predicate is forced both ways +// (iss-2609090951297149). The checkout does not exist, so neither spelling +// resolves to the other. +func TestLocalTranscriptRootsCaseVariantFollowsTheFoldPredicate(t *testing.T) { + parent := t.TempDir() + repo := filepath.Join(parent, "checkout-absent") + home := t.TempDir() + t.Setenv("HOME", home) + declareLocal(t, home, filepath.Join(parent, "CHECKOUT-ABSENT"), 0o600) + real := caseFoldingFS + t.Cleanup(func() { caseFoldingFS = real }) + + caseFoldingFS = func() bool { return true } + if ok, note := localDeclared(repo); !ok { + t.Errorf("fold on: a case-variant declaration of the checkout must pull it in; note %q", note) + } + caseFoldingFS = func() bool { return false } + if ok, note := localDeclared(repo); ok { + t.Errorf("fold off: a case-variant declaration names a different checkout and must pull nothing in; note %q", note) + } +} diff --git a/internal/core/history/location.go b/internal/core/history/location.go index a6592184f..46d3313b0 100644 --- a/internal/core/history/location.go +++ b/internal/core/history/location.go @@ -52,7 +52,6 @@ import ( "time" "github.com/intentdriven/abcd/internal/fsutil" - "github.com/intentdriven/abcd/internal/termsafe" ) const ( @@ -216,60 +215,26 @@ func localDeclared(repoRoot string) (bool, string) { if err != nil || home == "" { return false, "" } - // The guard is fsutil.ReadHomeDeclaration's, not this function's — see the - // note at rules.trustedRootDeclared. Only the WORDING stays here. - raw, refusal, err := fsutil.ReadHomeDeclaration(home, LocalRootsRelPath, maxLocalRootsBytes) - switch refusal { - case fsutil.DeclarationOK: - case fsutil.DeclarationAbsent: - return false, "" // no declaration is the ordinary case, not a diagnostic. - case fsutil.DeclarationBehindSymlink: - return false, ignoredDeclaration(termsafe.Sanitize(err.Error())) - case fsutil.DeclarationNotRegular: - return false, ignoredDeclaration("it is not a regular file") - case fsutil.DeclarationWritableByOthers: - return false, ignoredDeclaration("it is writable by others, so its contents are not necessarily yours") - case fsutil.DeclarationForeignOwner: - return false, ignoredDeclaration("it is not owned by this session's uid") - default: - return false, ignoredDeclaration("it could not be read (" + termsafe.Sanitize(err.Error()) + ")") + // The guard and the match are fsutil.HomeDeclarationNames' — see the note + // at rules.trustedRootDeclared. Only the WORDING stays here. + declared, why := fsutil.HomeDeclarationNames(home, LocalRootsRelPath, maxLocalRootsBytes, repoRoot, caseFoldingFS()) + if why != "" { + return false, ignoredDeclaration(why) } - fold := fsutil.CaseFoldingFS() - want := fsutil.FoldPath(repoRoot, fold) - for _, line := range strings.Split(string(raw), "\n") { - entry := strings.TrimSpace(line) - if entry == "" || strings.HasPrefix(entry, "#") { - continue - } - if !filepath.IsAbs(entry) { - continue // a relative entry names a different directory per caller. - } - // Both spellings: the entry as written, and symlink-resolved, because a - // declared path may not be resolved and repoRoot may be either. - for _, cand := range []string{filepath.Clean(entry), resolveOrClean(entry)} { - if fsutil.FoldPath(cand, fold) == want || fsutil.FoldPath(cand, fold) == fsutil.FoldPath(resolveOrClean(repoRoot), fold) { - return true, "" - } - } - } - return false, "" + return declared, "" } +// caseFoldingFS is the package's view of fsutil.CaseFoldingFS, held as a var +// so a detector can force the case-folding branch of the local-roots match on +// a case-sensitive host (iss-2609090951297149). +var caseFoldingFS = fsutil.CaseFoldingFS + // ignoredDeclaration renders the one-line reason a present declaration was not // honoured, naming the file in tilde form so no home path is carried. func ignoredDeclaration(why string) string { return "history: IGNORED " + LocalRootsDisplay + " — " + why + "; transcripts stay in " + "~/" + userStoreRelPath } -// resolveOrClean is EvalSymlinks with a lexical fallback, so a declared path -// that does not currently exist still compares. -func resolveOrClean(p string) string { - if real, err := filepath.EvalSymlinks(p); err == nil { - return real - } - return filepath.Clean(p) -} - // migrateLegacy moves a corpus stored under the legacy location into the // resolved store, and returns the one-line note saying so (empty when there was // nothing to move). diff --git a/internal/core/ideate/decoder_refusal_echo_test.go b/internal/core/ideate/decoder_refusal_echo_test.go new file mode 100644 index 000000000..634fbb75e --- /dev/null +++ b/internal/core/ideate/decoder_refusal_echo_test.go @@ -0,0 +1,27 @@ +package ideate + +import ( + "strings" + "testing" +) + +// TestDecoderRefusalRedactsTheKey — iss-2609290218032954. The verdict payload +// decodes strictly, and encoding/json's refusal names an undeclared field by +// the payload's own key. That message was returned raw, so a home path in a key +// reached the terminal and the transcript. The key is still named, redacted +// through the canonical scanner. +func TestDecoderRefusalRedactsTheKey(t *testing.T) { + root := seedRepo(t) + p := validPayload() + p["reviewer_notes /Users/zzotherperson/notes"] = "smuggled" // abcd-lint:allow — a planted home path in a KEY + _, err := Record(root, "the-ideate-gate", encode(t, p), at) + if err == nil { + t.Fatal("a payload carrying an undeclared key was accepted") + } + if strings.Contains(err.Error(), "zzotherperson") { + t.Errorf("the refusal echoes the payload's key: %v", err) + } + if !strings.Contains(err.Error(), "reviewer_notes") { + t.Errorf("the refusal no longer names the undeclared field: %v", err) + } +} diff --git a/internal/core/ideate/enum_refusal_test.go b/internal/core/ideate/enum_refusal_test.go new file mode 100644 index 000000000..494f3a6e4 --- /dev/null +++ b/internal/core/ideate/enum_refusal_test.go @@ -0,0 +1,67 @@ +package ideate + +import ( + "strings" + "testing" +) + +// enumLeak is what a composer pasted into a closed-set field: a home path and a +// distinctive marker, the shape the recorder redacts field by field in prose. +const enumLeak = "zzleak-7f3a /Users/zzotherperson/notes" // abcd-lint:allow — a planted home path the refusal must not echo + +// shortLeak is the same under the record-id length cap, so the id refusal that +// quotes rather than the one that counts bytes is the one reached. +const shortLeak = "zzleak-7f3a/Users/zzotherperson" + +// TestEnumRefusalsDoNotEchoTheValue — iss-2609090951295881. The recorder +// redacts every free-text field and its stage-two refusal names finding kinds +// only, because a refusal that quoted the span it refused would publish it into +// a terminal, a log and the session transcript. The closed-set refusals did the +// opposite: the verdict, the leg kind, a claim status, a grill relation, a kill +// outcome, and a grill hit's record id were interpolated into the error after +// only the terminal-escape cleaner, which strips control sequences and redacts +// nothing. Each is now described (its length, or that it is empty), never +// quoted, and the refusal still says which field and which item it was. +func TestEnumRefusalsDoNotEchoTheValue(t *testing.T) { + root := seedRepo(t) + legs := func(p map[string]any, i int) map[string]any { return p["legs"].([]any)[i].(map[string]any) } + cases := map[string]struct { + mutate func(p map[string]any) + names string + }{ + "verdict": {func(p map[string]any) { p["verdict"] = enumLeak }, "verdict"}, + "leg kind": {func(p map[string]any) { legs(p, 0)["kind"] = enumLeak }, "leg 1"}, + "claim status": {func(p map[string]any) { + legs(p, 0)["claims"].([]any)[0].(map[string]any)["status"] = enumLeak + }, "claim 1"}, + "grill record id": {func(p map[string]any) { + legs(p, 1)["hits"].([]any)[0].(map[string]any)["record"] = shortLeak + }, "grill hit 1"}, + "grill relation": {func(p map[string]any) { + legs(p, 1)["hits"].([]any)[0].(map[string]any)["relation"] = enumLeak + }, "grill hit 1"}, + "kill outcome": {func(p map[string]any) { + legs(p, 2)["kill_attempts"].([]any)[0].(map[string]any)["outcome"] = enumLeak + }, "kill attempt 1"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := validPayload() + tc.mutate(p) + _, err := Record(root, "the-ideate-gate", encode(t, p), at) + if err == nil { + t.Fatal("the out-of-set value was accepted") + } + msg := err.Error() + for _, part := range []string{"zzleak-7f3a", "zzotherperson", "/Users/"} { + if strings.Contains(msg, part) { + t.Errorf("the refusal echoes the refused value (%q): %s", part, msg) + } + } + if !strings.Contains(msg, tc.names) { + t.Errorf("the refusal no longer says which field it was (want %q): %s", tc.names, msg) + } + }) + } + assertNothingWritten(t, root) +} diff --git a/internal/core/ideate/record.go b/internal/core/ideate/record.go index 840ab6f39..d5565a33e 100644 --- a/internal/core/ideate/record.go +++ b/internal/core/ideate/record.go @@ -32,6 +32,7 @@ import ( "strings" "time" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/core/update" "github.com/intentdriven/abcd/internal/fsutil" @@ -110,7 +111,7 @@ func Record(repoRoot, slug string, raw []byte, at time.Time) (Result, error) { if err := validateSlug(slug); err != nil { return Result{}, err } - p, err := decodePayload(raw) + p, err := decodePayload(repoRoot, raw) if err != nil { return Result{}, err } @@ -237,7 +238,11 @@ func validateSlug(slug string) error { // an unknown-field refusal (an invented key means the composer and this core // disagree about the contract), a trailing-data refusal, the three-branch schema // gate, and the prompt_version stamp. -func decodePayload(raw []byte) (Payload, error) { +// +// The decoder's message names an undeclared field by the payload's own key, the +// one value the composer needs to find the fault, so it is redacted through the +// canonical scanner for repoRoot rather than returned raw (iss-2609290218032954). +func decodePayload(repoRoot string, raw []byte) (Payload, error) { if len(raw) > MaxPayloadBytes { return Payload{}, fmt.Errorf("verdict payload exceeds the %d-byte cap", MaxPayloadBytes) } @@ -245,7 +250,7 @@ func decodePayload(raw []byte) (Payload, error) { dec.DisallowUnknownFields() var p Payload if err := dec.Decode(&p); err != nil { - return Payload{}, fmt.Errorf("malformed verdict JSON: %v", err) + return Payload{}, fmt.Errorf("malformed verdict JSON: %s", scanner.RedactRefusal(repoRoot, err.Error())) } if dec.More() { return Payload{}, errors.New("the verdict payload carries trailing data after the JSON document") @@ -306,8 +311,8 @@ func validate(repoRoot string, red *recordRedactor, p Payload) (verdictDoc, erro return v, errors.New("the verdict payload carries no idea text; the record is about an idea, so the idea is required") } if !p.Verdict.Valid() { - return v, fmt.Errorf("out-of-enum verdict %q; a verdict is one of %s|%s|%s", - termsafe.Sanitize(string(p.Verdict)), VerdictSurvives, VerdictKilled, VerdictReframed) + return v, fmt.Errorf("out-of-enum verdict (%s); a verdict is one of %s|%s|%s", + termsafe.DescribeRefused(string(p.Verdict)), VerdictSurvives, VerdictKilled, VerdictReframed) } v.verdict = p.Verdict @@ -317,8 +322,8 @@ func validate(repoRoot string, red *recordRedactor, p Payload) (verdictDoc, erro } for i, leg := range p.Legs { if leg.Kind != legOrder[i] { - return v, fmt.Errorf("leg %d is %q; the legs run in order (%s), and the order is what each leg is looking at", - i+1, termsafe.Sanitize(string(leg.Kind)), legList()) + return v, fmt.Errorf("leg %d is not %s (its kind is %s); the legs run in order (%s), and the order is what each leg is looking at", + i+1, legOrder[i], termsafe.DescribeRefused(string(leg.Kind)), legList()) } } var err error @@ -373,8 +378,8 @@ func validateResearch(red *recordRedactor, leg Leg) ([]Claim, int, error) { return nil, 0, fmt.Errorf("claim %d names no primary source; a claim checked against nothing is an assertion", at) } if !claimStatusEnum[c.Status] { - return nil, 0, fmt.Errorf("claim %d has status %q; a claim is %s, %s, or %s", - at, termsafe.Sanitize(string(c.Status)), ClaimVerified, ClaimFalsified, ClaimUnverifiable) + return nil, 0, fmt.Errorf("claim %d has an out-of-enum status (%s); a claim is %s, %s, or %s", + at, termsafe.DescribeRefused(string(c.Status)), ClaimVerified, ClaimFalsified, ClaimUnverifiable) } out = append(out, Claim{Claim: text, PrimarySource: src, Status: c.Status}) } @@ -401,12 +406,12 @@ func validateGrill(red *recordRedactor, leg Leg) ([]GrillHit, int, error) { return nil, 0, fmt.Errorf("grill hit %d cites a %d-byte record id (max %d)", at, len(h.Record), maxRecordIDBytes) } if !recordid.CitedIDRe.MatchString(h.Record) { - return nil, 0, fmt.Errorf("grill hit %d cites %q, which is not a record id (want adr-N, itd-N, iss-N, or spc-N)", - at, termsafe.Sanitize(h.Record)) + return nil, 0, fmt.Errorf("grill hit %d cites %s, which is not a record id (want adr-N, itd-N, iss-N, or spc-N)", + at, termsafe.DescribeRefused(h.Record)) } if !relationEnum[h.Relation] { - return nil, 0, fmt.Errorf("grill hit %d (%s) has relation %q; a hit is %s, %s, or %s", - at, h.Record, termsafe.Sanitize(string(h.Relation)), RelationCovered, RelationContradicted, RelationSuperseded) + return nil, 0, fmt.Errorf("grill hit %d (%s) has an out-of-enum relation (%s); a hit is %s, %s, or %s", + at, h.Record, termsafe.DescribeRefused(string(h.Relation)), RelationCovered, RelationContradicted, RelationSuperseded) } // The cited id is NOT redacted: CitedIDRe has just proved it is a record // id, and rewriting a value the citation gate resolves against would break @@ -447,8 +452,8 @@ func validateAdversarial(red *recordRedactor, leg Leg) ([]KillAttempt, int, erro return nil, 0, fmt.Errorf("kill attempt %d has no text", at) } if !killOutcomeEnum[k.Outcome] { - return nil, 0, fmt.Errorf("kill attempt %d has outcome %q; an attempt is %s, %s, or %s", - at, termsafe.Sanitize(string(k.Outcome)), KillSurvived, KillPartial, KillFatal) + return nil, 0, fmt.Errorf("kill attempt %d has an out-of-enum outcome (%s); an attempt is %s, %s, or %s", + at, termsafe.DescribeRefused(string(k.Outcome)), KillSurvived, KillPartial, KillFatal) } out = append(out, KillAttempt{Attempt: text, Outcome: k.Outcome}) } diff --git a/internal/core/implement/loop/receipt.go b/internal/core/implement/loop/receipt.go index aa7f34ccb..e6717c266 100644 --- a/internal/core/implement/loop/receipt.go +++ b/internal/core/implement/loop/receipt.go @@ -22,9 +22,11 @@ import ( "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/jsonstrict" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" + "github.com/intentdriven/abcd/internal/termsafe" ) // ReceiptSchemaVersion is the receipt's shape. @@ -100,7 +102,7 @@ func verifyReceipt(c Context, lane *Lane, receiptRel string) error { return fmt.Errorf("opening the checkout: %w", err) } defer root.Close() - rc, err := readReceipt(root, receiptRel, lane.ID) + rc, err := readReceipt(c.RepoRoot, root, receiptRel, lane.ID) if err != nil { return err } @@ -109,11 +111,14 @@ func verifyReceipt(c Context, lane *Lane, receiptRel string) error { if rc.SchemaVersion != ReceiptSchemaVersion { missing = append(missing, fmt.Sprintf("schema_version %d (this abcd reads %d)", rc.SchemaVersion, ReceiptSchemaVersion)) } + // The receipt is the implementer's, a host payload: a value it names that + // is not the lane's own is described, never quoted (iss-2609290300462829). if rc.RunID != c.State.RunID || rc.Lane != lane.ID { - missing = append(missing, fmt.Sprintf("the run and lane (it names %q %q, not %s %s)", rc.RunID, rc.Lane, c.State.RunID, lane.ID)) + missing = append(missing, fmt.Sprintf("the run and lane (it names %s and %s, not %s %s)", + termsafe.DescribeRefused(rc.RunID), termsafe.DescribeRefused(rc.Lane), c.State.RunID, lane.ID)) } if rc.Branch != lane.Branch { - missing = append(missing, fmt.Sprintf("the lane's branch (it names %q, not %s)", rc.Branch, lane.Branch)) + missing = append(missing, fmt.Sprintf("the lane's branch (it names %s, not %s)", termsafe.DescribeRefused(rc.Branch), lane.Branch)) } missing = append(missing, commitGaps(c.RepoRoot, lane, rc.Commits)...) @@ -134,11 +139,11 @@ func verifyReceipt(c Context, lane *Lane, receiptRel string) error { } else if *dod.ExitCode != 0 { missing = append(missing, fmt.Sprintf("a passing definition of done (it exited %d)", *dod.ExitCode)) } - if gap := laneFileGap(dir, dod.Output, "the definition of done's output"); gap != "" { + if gap := laneFileGap(c.RepoRoot, dir, dod.Output, "the definition of done's output"); gap != "" { missing = append(missing, gap) } } - if gap := laneFileGap(dir, rc.Report, "the report"); gap != "" { + if gap := laneFileGap(c.RepoRoot, dir, rc.Report, "the report"); gap != "" { missing = append(missing, gap) } if len(missing) > 0 { @@ -156,8 +161,11 @@ func verifyReceipt(c Context, lane *Lane, receiptRel string) error { // readReceipt reads a receipt through the guarded reader and decodes it // strictly: one JSON document, no key repeated, no field the schema does not -// name. -func readReceipt(root *os.Root, rel, laneID string) (LaneReceipt, error) { +// name. The decoder's message names an undeclared or repeated key by the +// receipt's own spelling, the one value the implementer needs to find the +// fault, so it is redacted through the canonical scanner for repoRoot rather +// than quoted raw (iss-2609290300462829). +func readReceipt(repoRoot string, root *os.Root, rel, laneID string) (LaneReceipt, error) { var rc LaneReceipt data, err := fsutil.ReadGuardedInRoot(root, rel, maxReceiptBytes) if errors.Is(err, fs.ErrNotExist) { @@ -176,7 +184,7 @@ func readReceipt(root *os.Root, rel, laneID string) (LaneReceipt, error) { return rc, refuse("receipt", "", laneID, rel+" carries more than one JSON document", "write the receipt as one JSON object and nothing after it") } - return rc, refuse("receipt", "", laneID, fmt.Sprintf("%s does not parse as a receipt: %v", rel, err), + return rc, refuse("receipt", "", laneID, fmt.Sprintf("%s does not parse as a receipt: %s", rel, scanner.RedactRefusal(repoRoot, err.Error())), "write exactly the fields the brief names, each once; a verdict is the loop's to record, never the lane's") } return rc, nil @@ -198,7 +206,7 @@ func commitGaps(repoRoot string, lane *Lane, commits []string) []string { var off []string for _, sha := range commits { if !gitutil.IsFullSHA(sha) { - off = append(off, fmt.Sprintf("%q (not a full object name)", sha)) + off = append(off, termsafe.DescribeRefused(sha)+" (not a full object name)") continue } onBranch, err := gitutil.IsAncestor(repoRoot, sha, branch) @@ -227,23 +235,37 @@ func commitGaps(repoRoot string, lane *Lane, commits []string) []string { // laneFileGap names a file a receipt must point at inside the lane's // directory, when the receipt names none, names a path that could leave the // directory, or names one that is not a non-empty regular file there. -func laneFileGap(dir *os.Root, rel, what string) string { +// +// The path is the receipt's, a host payload. One that could leave the directory +// is described; one inside it is what the implementer needs to find, so it is +// named redacted through the canonical scanner for repoRoot, and a read error +// is reported by its cause alone, since its text repeats the path +// (iss-2609290300462829). +func laneFileGap(repoRoot string, dir *os.Root, rel, what string) string { switch { case rel == "": return what + " (none named)" case !fsutil.ValidRelPath(rel) || filepath.IsAbs(rel): - return fmt.Sprintf("%s (%q is not a path inside the lane's directory)", what, rel) + return fmt.Sprintf("%s (%s is not a path inside the lane's directory)", what, termsafe.DescribeRefused(rel)) } fi, err := dir.Lstat(rel) + if err == nil && fi.Mode().IsRegular() && fi.Size() > 0 { + return "" + } + named := scanner.RedactRefusal(repoRoot, rel) switch { case errors.Is(err, fs.ErrNotExist): - return fmt.Sprintf("%s (%s does not exist)", what, rel) + return fmt.Sprintf("%s (%s does not exist)", what, named) case err != nil: - return fmt.Sprintf("%s (%s cannot be read: %v)", what, rel, err) + cause := err + var pe *fs.PathError + if errors.As(err, &pe) { + cause = pe.Err + } + return fmt.Sprintf("%s (%s cannot be read: %v)", what, named, cause) case !fi.Mode().IsRegular(): - return fmt.Sprintf("%s (%s is not a regular file)", what, rel) - case fi.Size() == 0: - return fmt.Sprintf("%s (%s is empty)", what, rel) + return fmt.Sprintf("%s (%s is not a regular file)", what, named) + default: + return fmt.Sprintf("%s (%s is empty)", what, named) } - return "" } diff --git a/internal/core/implement/loop/receipt_refusal_echo_test.go b/internal/core/implement/loop/receipt_refusal_echo_test.go new file mode 100644 index 000000000..f0b23b668 --- /dev/null +++ b/internal/core/implement/loop/receipt_refusal_echo_test.go @@ -0,0 +1,76 @@ +package loop + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// receiptLeak is a receipt value no refusal may carry back: a marker and a +// third party's absolute home path. +const receiptLeak = "zzleak-7f3a /Users/zzotherperson/notes" // abcd-lint:allow — a planted home path the refusal must not echo + +// TestReceiptRefusalsDoNotEchoThePayload — iss-2609290300462829. The receipt is +// written by the implementer agent, a host payload, and its refusals quoted the +// strict decoder's message raw, a foreign run, lane and branch, a malformed +// commit name and a path outside the lane's directory with %q or %v. A token +// or a home path in any of them reached the terminal and the transcript. A +// value is described now, a path inside the lane's directory and an undeclared +// key are named redacted, and each refusal still names what is missing. +func TestReceiptRefusalsDoNotEchoThePayload(t *testing.T) { + // The harness pins HOME per test (awaitingLane), so the caller's home is + // read when the receipt is written, and must not come back either. + cases := []struct { + name string + edit func(t *testing.T, repo *gittest.Repo, l Lane, dir string, rc *LaneReceipt) any + names string + leaks []string + }{ + {"undeclared key", func(t *testing.T, _ *gittest.Repo, _ Lane, _ string, rc *LaneReceipt) any { + b, _ := json.Marshal(rc) + return strings.Replace(string(b), `"schema_version":1`, + `"schema_version":1,"reviewer_notes /Users/zzotherperson/notes `+os.Getenv("HOME")+`/x":1`, 1) // abcd-lint:allow — a planted home path in a KEY + }, "reviewer_notes", []string{"zzotherperson", "$HOME"}}, + {"run and lane", func(t *testing.T, _ *gittest.Repo, _ Lane, _ string, rc *LaneReceipt) any { + rc.RunID, rc.Lane = receiptLeak, receiptLeak + return rc + }, "the run and lane", []string{"zzleak-7f3a", "zzotherperson"}}, + {"branch", func(t *testing.T, _ *gittest.Repo, _ Lane, _ string, rc *LaneReceipt) any { + rc.Branch = receiptLeak + return rc + }, "the lane's branch", []string{"zzleak-7f3a", "zzotherperson"}}, + {"commit name", func(t *testing.T, _ *gittest.Repo, _ Lane, _ string, rc *LaneReceipt) any { + rc.Commits = append(rc.Commits, receiptLeak) + return rc + }, "not a full object name", []string{"zzleak-7f3a", "zzotherperson"}}, + {"report outside the lane's directory", func(t *testing.T, _ *gittest.Repo, _ Lane, _ string, rc *LaneReceipt) any { + rc.Report = "/Users/zzotherperson/notes/report.md" // abcd-lint:allow — a planted home path the refusal must not echo + return rc + }, "is not a path inside the lane's directory", []string{"zzotherperson"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo, runID, l, dir := awaitingLane(t) + c1 := laneCommit(t, repo, l, "one.txt") + rc := goodReceipt(t, runID, l, dir, c1) + path := writeReceipt(t, dir, tc.edit(t, repo, l, dir, &rc)) + + _, err := Receipt(repo.Root(), runID, path, DefaultSteps(), Options{}) + r := mustRefusal(t, err) + for _, leak := range tc.leaks { + if leak == "$HOME" { + leak = os.Getenv("HOME") + } + if strings.Contains(err.Error(), leak) || strings.Contains(r.Reason, leak) { + t.Errorf("the refusal echoes the receipt (%q): %q", leak, r.Reason) + } + } + if !strings.Contains(r.Reason, tc.names) { + t.Errorf("the refusal no longer names %q: %q", tc.names, r.Reason) + } + }) + } +} diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index 5c224ebe3..bf6c36f7d 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -865,7 +865,11 @@ func IngestVerdictBytes(repoRoot string, raw []byte) (IngestVerdictResult, error return IngestVerdictResult{}, fmt.Errorf("intent: verdict is not parseable JSON; refusing to ingest: %w", err) } if lenient.Type != VerdictType { - return IngestVerdictResult{}, fmt.Errorf("intent: verdict _type %q is not %q; refusing to ingest", lenient.Type, VerdictType) + // Refused before any receipt resolves, so no redactor is built yet: the + // value is described, never quoted, as every other host-payload refusal + // is (iss-2609290033521472). The wanted type beside it finds a typo. + return IngestVerdictResult{}, fmt.Errorf("intent: verdict _type is %s, not %q; refusing to ingest", + termsafe.DescribeRefused(lenient.Type), VerdictType) } if !rcpIDRe.MatchString(lenient.ReceiptID) { return IngestVerdictResult{}, fmt.Errorf("intent: verdict has no resolvable receipt_id (malformed or absent); refusing to ingest") @@ -1305,7 +1309,10 @@ func deadLetter(repoRoot string, it Intent, content, rcp string, raw []byte, rea return IngestVerdictResult{ Status: "dead_letter", ReceiptID: rcp, IntentID: it.ID, Conditions: len(untested), Untested: len(untested), - DeadLetterPath: dlRel, Reason: reason, + // The reason quotes the payload, and it reaches the terminal and the + // transcript from here, so it is redacted exactly as the record's copy + // is: a surface must not print what the record was protected from. + DeadLetterPath: dlRel, Reason: free(reason), ReadingOccasionedStanding: occasionedStanding(updated), }, nil } diff --git a/internal/core/intent/audit_redaction_test.go b/internal/core/intent/audit_redaction_test.go index c076405cc..f6dbb1a8c 100644 --- a/internal/core/intent/audit_redaction_test.go +++ b/internal/core/intent/audit_redaction_test.go @@ -315,3 +315,57 @@ func TestVerdictHashesAreValidatedShapes(t *testing.T) { "so an unknown digest is empty rather than wrong", res.Status) } } + +// TestDeadLetterReasonIsRedactedWhereItIsReturned — iss-2609290033521472. The +// dead-letter RECORD redacts its reason, which quotes the payload, but the +// result handed back to the surface carried the same reason raw, so the +// terminal and the transcript got what the record was protected from. +func TestDeadLetterReasonIsRedactedWhereItIsReturned(t *testing.T) { + root := identityRepo(t) + const rcp = "rcp-0123456789ab" + writeFile(t, root, shippedDir+"/itd-10-alpha.md", shippedWithMarker("itd-10", "alpha", "spc-1", "OWED", rcp)) + var m map[string]any + if err := json.Unmarshal([]byte(leakyVerdict(t, rcp, "cond-2609021016272867")), &m); err != nil { + t.Fatal(err) + } + m["criteria"].([]any)[0].(map[string]any)["verdict"] = "MET on buildbox.local per Jonathan Kensington-Pryce at /Users/zzotherperson/x" // abcd-lint:allow — planted leak + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + t.Fatal(err) + } + res, err := IngestVerdict(root, writeVerdict(t, root, string(raw))) + if err != nil { + t.Fatalf("IngestVerdict: %v", err) + } + if res.Status != "dead_letter" { + t.Fatalf("status = %q, want dead_letter", res.Status) + } + assertNoLeak(t, res.Reason) +} + +// TestWrongTypeRefusalDoesNotEchoTheValue — iss-2609290033521472. A payload +// whose _type is not the fidelity-verdict type is refused before any receipt +// resolves, and that refusal quoted the _type verbatim, so a home path or a +// token pasted there reached the terminal and the transcript. The refusal +// describes the value instead and still names the field and the wanted type. +func TestWrongTypeRefusalDoesNotEchoTheValue(t *testing.T) { + root := t.TempDir() + rcp := shipOne(t, root) + const planted = "/Users/zzotherperson/.config/tok-SENTINEL-7f3a" // abcd-lint:allow — planted leak + payload := strings.Replace(validVerdict(rcp), "abcd/intent-fidelity-verdict/v1", planted, 1) + _, err := IngestVerdict(root, writeVerdict(t, root, payload)) + if err == nil { + t.Fatal("ingest must reject a payload whose _type is not the fidelity-verdict type") + } + msg := err.Error() + for _, leak := range []string{"zzotherperson", "SENTINEL"} { + if strings.Contains(msg, leak) { + t.Errorf("the refusal echoes the refused _type (%q found): %s", leak, msg) + } + } + for _, want := range []string{"_type", VerdictType, "not quoted"} { + if !strings.Contains(msg, want) { + t.Errorf("the refusal does not carry %q: %s", want, msg) + } + } +} diff --git a/internal/core/intent/claims_fence_test.go b/internal/core/intent/claims_fence_test.go index ab91a5a55..203cb9cf9 100644 --- a/internal/core/intent/claims_fence_test.go +++ b/internal/core/intent/claims_fence_test.go @@ -6,7 +6,6 @@ import ( "regexp" "strings" "testing" - "time" "github.com/intentdriven/abcd/internal/core/mdrecord" ) @@ -176,33 +175,25 @@ func fenceBlindSectionBody(content string, headRe *regexp.Regexp) string { // record two sessions can reach at once, and the ids it writes come from a mint // that reads no ledger. It takes the same advisory lock every other mint in this // package takes, so a concurrent create cannot interleave with it. +// +// It asserts the observed ordering its sibling in grounds_test.go asserts, +// never a wait, so no machine speed can flip the verdict +// (iss-2608301301041887). func TestStampPlannedHoldsTheMintLock(t *testing.T) { root := t.TempDir() writeFile(t, root, plannedDir+"/itd-10-alpha.md", "---\nid: itd-10\nslug: alpha\nspec_id: spc-1\nkind: standalone\n---\n# alpha\n\n"+ "## Scope Conditions\n\n- holds on POSIX\n\n## Acceptance Criteria\n\n- ok\n") - held := make(chan struct{}) - done := make(chan error, 1) - go func() { - done <- withIntentMintLock(root, func() error { - close(held) - // Hold the lock long enough that an unlocked stamp would finish inside it. - time.Sleep(150 * time.Millisecond) - return nil - }) - }() - <-held - start := time.Now() - if _, err := Plan(root, "itd-10", PlanOptions{}); err != nil { - t.Fatal(err) - } - waited := time.Since(start) - if err := <-done; err != nil { + released, finish := holdMintLockUntilContended(t, root) + _, err := Plan(root, "itd-10", PlanOptions{}) + wasReleased := released.Load() + finish() + if err != nil { t.Fatal(err) } - if waited < 100*time.Millisecond { - t.Fatalf("the stamp completed in %v while the mint lock was held — it took no lock", waited) + if !wasReleased { + t.Fatal("the stamp returned while the mint lock was still held, having never contended for it — it took no lock") } } diff --git a/internal/core/intent/consistency.go b/internal/core/intent/consistency.go index 831edc57c..b3e4cdb8a 100644 --- a/internal/core/intent/consistency.go +++ b/internal/core/intent/consistency.go @@ -22,6 +22,7 @@ import ( "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" + "github.com/intentdriven/abcd/internal/termsafe" ) // consistency.go — Role 2 of the intent-auditor: the cross-document @@ -801,7 +802,8 @@ func validateConsistency(repoRoot string, raw []byte) (consistencyReview, error) return consistencyReview{}, fmt.Errorf("intent: findings are not parseable JSON; refusing to ingest: %w", err) } if lenient.Type != ConsistencyType { - return consistencyReview{}, fmt.Errorf("intent: findings _type %q is not %q; refusing to ingest", lenient.Type, ConsistencyType) + return consistencyReview{}, fmt.Errorf("intent: findings _type is %s, which is not %q; refusing to ingest", + termsafe.DescribeRefused(lenient.Type), ConsistencyType) } if !rcpIDRe.MatchString(lenient.ReceiptID) { return consistencyReview{}, fmt.Errorf("intent: findings carry no resolvable receipt_id (malformed or absent); refusing to ingest") @@ -863,14 +865,15 @@ func validateConsistency(repoRoot string, raw []byte) (consistencyReview, error) dec.DisallowUnknownFields() var p consistencyPayload if err := dec.Decode(&p); err != nil { - return consistencyReview{}, fmt.Errorf("intent: malformed findings JSON: %v; refusing to ingest", err) + return consistencyReview{}, fmt.Errorf("intent: malformed findings JSON: %s; refusing to ingest", redactRefused(repoRoot, err.Error())) } if dec.More() { return consistencyReview{}, fmt.Errorf("intent: findings JSON carries more than one value; refusing to ingest") } for _, h := range [][2]string{{"policy.rubric_hash", p.Policy.RubricHash}, {"policy.prompt_hash", p.Policy.PromptHash}} { if !sha256FieldRe.MatchString(h[1]) { - return consistencyReview{}, fmt.Errorf("intent: %s is required as sha256:<64 lowercase hex>, not %q; refusing to ingest", h[0], oneLine(h[1])) + return consistencyReview{}, fmt.Errorf("intent: %s is required as sha256:<64 lowercase hex>, not %s; refusing to ingest", + h[0], termsafe.DescribeRefused(h[1])) } } want := consistencyPolicyFor(consistencyPromptBody(c, rcp)) @@ -935,10 +938,10 @@ func validateConsistencyFindings(repoRoot string, c consistencyCorpus, in []cons for i, f := range in { n := i + 1 if !classes[f.Class] { - return nil, fmt.Errorf("finding %d has class %q, not one of %s", n, oneLine(f.Class), strings.Join(ConsistencyClasses, " | ")) + return nil, fmt.Errorf("finding %d has class %s, not one of %s", n, termsafe.DescribeRefused(f.Class), strings.Join(ConsistencyClasses, " | ")) } if !severities[f.Severity] { - return nil, fmt.Errorf("finding %d has severity %q, not one of %s", n, oneLine(f.Severity), strings.Join(issueschema.Severities, " | ")) + return nil, fmt.Errorf("finding %d has severity %s, not one of %s", n, termsafe.DescribeRefused(f.Severity), strings.Join(issueschema.Severities, " | ")) } if strings.TrimSpace(f.Summary) == "" || strings.TrimSpace(f.Explanation) == "" { return nil, fmt.Errorf("finding %d states no summary or no explanation", n) @@ -986,18 +989,23 @@ func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " " // locateEnd resolves one end against the corpus: its path must be a manifest // document, and its quote must occur in the document as the corpus presents it. // The line is where the quote begins in the file on disk. +// +// A refused path or quote is DESCRIBED, never quoted: the finding and end +// numbers the caller prefixes locate it in the payload, and the value itself +// reaches the terminal and the transcript with no redaction on the way +// (iss-2609290144116254). func locateEnd(repoRoot string, c consistencyCorpus, e consistencyEndJSON) (ConsistencyEnd, error) { path := strings.TrimSpace(e.Path) d, ok := c.doc(path) if !ok { - return ConsistencyEnd{}, fmt.Errorf("path %q is not a document in the corpus manifest", oneLine(path)) + return ConsistencyEnd{}, fmt.Errorf("path is %s, which is not a document in the corpus manifest", termsafe.DescribeRefused(path)) } q := collapseSpace(e.Quote) if len([]rune(q)) < minQuoteChars { return ConsistencyEnd{}, fmt.Errorf("the quote from %s is shorter than %d characters; quote enough to locate it", path, minQuoteChars) } if _, ok := findCollapsed(d.Text, q); !ok { - return ConsistencyEnd{}, fmt.Errorf("the quote %q does not occur in %s as the corpus presents it", oneLine(q), path) + return ConsistencyEnd{}, fmt.Errorf("the quote (%s) does not occur in %s as the corpus presents it", termsafe.DescribeRefused(q), path) } line := 0 if data, err := readRepoFile(filepath.Join(repoRoot, filepath.FromSlash(path)), path); err == nil { diff --git a/internal/core/intent/consistency_refusal_echo_test.go b/internal/core/intent/consistency_refusal_echo_test.go new file mode 100644 index 000000000..2838fb534 --- /dev/null +++ b/internal/core/intent/consistency_refusal_echo_test.go @@ -0,0 +1,81 @@ +package intent + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// TestConsistencyRefusalsDoNotEchoThePayload — iss-2609290144116254. Every +// consistency refusal returns to the surface with nothing written, and six of +// them carried the payload's own value back: the _type with a bare %q, a policy +// hash, a finding's class and severity, and an end's path and quote through +// oneLine, which cleans and caps but does not redact. A token or a home path in +// any of them reached the terminal and the transcript. Each is described now, +// and the refusal still names the field and where it sits; the one refusal whose +// value the reader needs — an undeclared field's NAME — is redacted through the +// canonical scanner and still named. +func TestConsistencyRefusalsDoNotEchoThePayload(t *testing.T) { + const leak = "zzleak-7f3a /Users/zzotherperson/notes long enough to quote" // abcd-lint:allow — a planted home path the refusal must not echo + finding := func(m map[string]any) map[string]any { return m["findings"].([]any)[0].(map[string]any) } + end := func(m map[string]any) map[string]any { return finding(m)["ends"].([]any)[1].(map[string]any) } + cases := []struct { + name string + mutate func(m map[string]any) + names string + }{ + {"_type", func(m map[string]any) { m["_type"] = leak }, "_type"}, + {"rubric_hash", func(m map[string]any) { m["policy"].(map[string]any)["rubric_hash"] = leak }, "policy.rubric_hash"}, + {"class", func(m map[string]any) { finding(m)["class"] = leak }, "class"}, + {"severity", func(m map[string]any) { finding(m)["severity"] = leak }, "severity"}, + {"end path", func(m map[string]any) { end(m)["path"] = leak }, "path"}, + {"end quote", func(m map[string]any) { end(m)["quote"] = leak }, "quote"}, + {"undeclared field", func(m map[string]any) { m["reviewer_notes /Users/zzotherperson/notes"] = "x" }, "reviewer_notes"}, // abcd-lint:allow — a planted home path in a KEY + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := consistencyRepo(t) + root := r.Root() + em, err := EmitConsistency(root, "", ConsistencyEmitOptions{}) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(findingsPayload(t, root, em, contradiction()), &m); err != nil { + t.Fatal(err) + } + tc.mutate(m) + payload, _ := json.Marshal(m) + _, err = ingest(t, root, payload, &fakeFiler{}) + if err == nil { + t.Fatalf("a payload carrying the leak in %s was accepted", tc.name) + } + for _, part := range []string{"zzleak-7f3a", "zzotherperson"} { + if strings.Contains(err.Error(), part) { + t.Errorf("the refusal echoes the refused %s: %v", tc.name, err) + } + } + if !strings.Contains(err.Error(), tc.names) { + t.Errorf("the refusal no longer names %s: %v", tc.names, err) + } + }) + } +} + +// TestRefusedKeySweepsTheCallerHome — iss-2609290144116254. redactRefused was a +// second copy of scanner.RedactRefusal without its literal sweep of the caller's +// home, so on a checkout the identity probe says nothing about, a key carrying +// the caller's own home path was named with it. It routes through the one +// canonical primitive now: the home is swept to `~` and the key still named. +func TestRefusedKeySweepsTheCallerHome(t *testing.T) { + home := filepath.Join(t.TempDir(), "zzcallerhome") + t.Setenv("HOME", home) + got := redactRefused(t.TempDir(), `json: unknown field "reviewer_notes `+home+`/notes"`) + if strings.Contains(got, "zzcallerhome") { + t.Errorf("the refusal names the caller's home: %q", got) + } + if !strings.Contains(got, "reviewer_notes ~/notes") { + t.Errorf("the refusal lost the key or did not sweep the home to ~: %q", got) + } +} diff --git a/internal/core/intent/create.go b/internal/core/intent/create.go index 5bbd70302..e5ed5eab9 100644 --- a/internal/core/intent/create.go +++ b/internal/core/intent/create.go @@ -628,6 +628,13 @@ func titleLine(text string) string { // locked read — not the corpus — is what the verb judges. var beforeIntentMintLock func() +// onIntentMintLockBusy is a test seam, nil outside tests: called each time an +// attempt to take the lock finds it already held. A test that proves a writer +// takes the lock has to OBSERVE the writer blocked on it; inferring it from how +// long the write took measures the machine, and a writer that takes no lock but +// is slow for its own reasons passes a wait bar (iss-2608301301041887). +var onIntentMintLockBusy func() + // withIntentMintLock runs fn while holding an exclusive advisory lock over the // intent store. It serializes the presence check and the write of one mint // against concurrent abcd processes in the SAME checkout (two agent sessions, a @@ -670,6 +677,9 @@ func withIntentMintLockWithin(repoRoot string, timeout time.Duration, fn func() if lockErr != syscall.EWOULDBLOCK { return fmt.Errorf("intent: acquiring mint lock: %w", lockErr) } + if onIntentMintLockBusy != nil { + onIntentMintLockBusy() + } if time.Now().After(deadline) { return fmt.Errorf("%w within %s", errIntentLockBusy, timeout) } diff --git a/internal/core/intent/grounds.go b/internal/core/intent/grounds.go index 592bd6c5c..0c244252b 100644 --- a/internal/core/intent/grounds.go +++ b/internal/core/intent/grounds.go @@ -148,8 +148,9 @@ func RecordGrounds(repoRoot, intentID string, g grounds.Grounds) (GroundsResult, // the whole file it would match a frontmatter `# Grounds` comment — a legal YAML // comment the block parser skips and an ATX heading pattern matches — as the // section, and report an empty pseudo-section about a record whose body carries -// its entries (iss-2608301805069999). Callers pass whole records and bodies -// alike; grounds.Body takes either. +// its entries (iss-2608301805069999). Callers pass the whole record: a bare +// body passes through grounds.Body whole only while it does not open with a +// thematic break, which Body reads as a frontmatter opener. func ParseGrounds(content string) []grounds.Grounds { return grounds.ParseSectionAboveFloor(grounds.Body(content)) } diff --git a/internal/core/intent/grounds_test.go b/internal/core/intent/grounds_test.go index df727071a..a8e4a3c8c 100644 --- a/internal/core/intent/grounds_test.go +++ b/internal/core/intent/grounds_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" - "time" "github.com/intentdriven/abcd/internal/core/grounds" ) @@ -312,32 +312,64 @@ func TestRecordGroundsConcurrentAppendsBothLand(t *testing.T) { // TestRecordGroundsHoldsTheMintLock is the deterministic half: the read, the // append and the write are one critical section under the same advisory lock // every other writer in this package takes, so a concurrent holder blocks it. +// +// It asserts an observed ORDERING, not a wait (iss-2608301301041887): the holder +// releases only once the writer's own attempt on the lock has been refused, so a +// writer that takes no lock never contends and returns while the holder still +// has it — whatever the machine's load or the writer's own speed. func TestRecordGroundsHoldsTheMintLock(t *testing.T) { root := t.TempDir() writeFile(t, root, plannedDir+"/itd-10-alpha.md", plannedUnlinked("itd-10", "alpha")) + released, finish := holdMintLockUntilContended(t, root) + g := mustGrounds(t, grounds.Pursued, "we expect the grounds write to serialize with every other writer") + _, err := RecordGrounds(root, "itd-10", g) + wasReleased := released.Load() + finish() + if err != nil { + t.Fatal(err) + } + if !wasReleased { + t.Fatal("the grounds write returned while the mint lock was still held, having never contended for it — it took no lock") + } +} + +// holdMintLockUntilContended takes the intent mint lock on another goroutine and +// holds it until a second attempt on the lock is refused, then releases it. The +// flag it returns is raised as the last act inside the holder's critical +// section, so a writer that returns with it down never waited for the lock. +// finish releases a holder that saw no contention (the failing direction) and +// waits for it, so no goroutine outlives the test. +func holdMintLockUntilContended(t *testing.T, root string) (*atomic.Bool, func()) { + t.Helper() + contended := make(chan struct{}) + var once sync.Once + onIntentMintLockBusy = func() { once.Do(func() { close(contended) }) } + t.Cleanup(func() { onIntentMintLockBusy = nil }) + + released := &atomic.Bool{} held := make(chan struct{}) + stop := make(chan struct{}) done := make(chan error, 1) go func() { done <- withIntentMintLock(root, func() error { close(held) - // Hold the lock long enough that an unlocked write would finish inside it. - time.Sleep(150 * time.Millisecond) + select { + case <-contended: + case <-stop: + return nil // released without contention: the flag stays down + } + released.Store(true) return nil }) }() <-held - start := time.Now() - g := mustGrounds(t, grounds.Pursued, "we expect the grounds write to serialize with every other writer") - if _, err := RecordGrounds(root, "itd-10", g); err != nil { - t.Fatal(err) - } - waited := time.Since(start) - if err := <-done; err != nil { - t.Fatal(err) - } - if waited < 100*time.Millisecond { - t.Fatalf("the grounds write completed in %v while the mint lock was held — it took no lock", waited) + var stopOnce sync.Once + return released, func() { + stopOnce.Do(func() { close(stop) }) + if err := <-done; err != nil { + t.Fatalf("the lock holder: %v", err) + } } } diff --git a/internal/core/intent/redact.go b/internal/core/intent/redact.go index 69c0fa8c1..f187c51de 100644 --- a/internal/core/intent/redact.go +++ b/internal/core/intent/redact.go @@ -76,3 +76,17 @@ func newIntentRedactor(repoRoot string) (intentRedactor, error) { return scanner.Redact(text, findings) }, nil } + +// redactRefused renders payload text for a refusal the consistency ingest +// RETURNS with nothing written: the decoder's message, which names an +// undeclared field by the payload's own key. That name is what the reader needs +// to find the fault, so it is kept and redacted rather than described +// (iss-2609290144116254). +// +// It is scanner.RedactRefusal, the one canonical refusal redactor (canonical +// patterns, then the literal sweep of the caller's home), followed by oneLine's +// cap. It FAILS CLOSED the way newIntentRedactor does: a scanner that cannot be +// built, or runs degraded, leaves the text described and never echoed. +func redactRefused(repoRoot, s string) string { + return oneLine(scanner.RedactRefusal(repoRoot, s)) +} diff --git a/internal/core/lifeboat/decoder_refusal_echo_test.go b/internal/core/lifeboat/decoder_refusal_echo_test.go new file mode 100644 index 000000000..cbd76d1c4 --- /dev/null +++ b/internal/core/lifeboat/decoder_refusal_echo_test.go @@ -0,0 +1,52 @@ +package lifeboat + +import ( + "strings" + "testing" +) + +// TestSynthesisDecoderRefusalsRedactTheKey — iss-2609290218032954. The four +// host-composed lifeboat payloads decode strictly, and encoding/json's refusal +// names an undeclared field by the payload's own key. That message was returned +// raw, so a token or a home path in a key reached the terminal and the +// transcript. The key is still named, redacted through the canonical scanner. +func TestSynthesisDecoderRefusalsRedactTheKey(t *testing.T) { + const key = `"reviewer_notes /Users/zzotherperson/notes":1` // abcd-lint:allow — a planted home path in a KEY + cases := map[string]func(t *testing.T) error{ + "lessons": func(t *testing.T) error { + _, err := IngestLessons(stdFixture(t), []byte(`{"schema_version":1,`+key+`,"lessons":[]}`)) + return err + }, + "press release": func(t *testing.T) error { + dir := synthLifeboat(t, map[string]string{briefPressReleasePath: briefPR}) + _, err := ComposePressRelease(dir, []byte(`{"schema_version":1,"mode":"delegated","prompt_version":"0.1.0",`+ + `"headline":"h","body":"b","evidence":["brief/x"],`+key+`}`)) + return err + }, + "principles": func(t *testing.T) error { + _, err := SynthesizePrinciples(adrLifeboat(t), []byte(`{"schema_version":1,"mode":"delegated",`+ + `"prompt_version":"0.1.0","principles":[],`+key+`}`)) + return err + }, + "review": func(t *testing.T) error { + dir := reviewFixture(t, "abc", &Summary{Grounded: 7, Blank: 3}) + _, err := ReviewLifeboat(dir, realSourceDir(t), []byte(`{"schema_version":1,"mode":"delegated",`+ + `"prompt_version":"0.1.0","verdict":"SHIP","findings":[],`+key+`}`)) + return err + }, + } + for name, run := range cases { + t.Run(name, func(t *testing.T) { + err := run(t) + if err == nil { + t.Fatal("a payload carrying an undeclared key was accepted") + } + if strings.Contains(err.Error(), "zzotherperson") { + t.Errorf("the refusal echoes the payload's key: %v", err) + } + if !strings.Contains(err.Error(), "reviewer_notes") { + t.Errorf("the refusal no longer names the undeclared field: %v", err) + } + }) + } +} diff --git a/internal/core/lifeboat/embark.go b/internal/core/lifeboat/embark.go index 37f2d9375..c9ae2068d 100644 --- a/internal/core/lifeboat/embark.go +++ b/internal/core/lifeboat/embark.go @@ -705,7 +705,7 @@ func walkLifeboatFilesBounded(root *os.Root, limit, perDir, maxDepth int) ([]str } sub, err := openWalkDir(dirRoot, name) if err != nil { - return err + return walkOpenRefusal(rel, err) } err = walk(sub, rel, depth+1) sub.Close() @@ -728,6 +728,18 @@ func walkLifeboatFilesBounded(root *os.Root, limit, perDir, maxDepth int) ([]str return rels, nil } +// walkOpenRefusal names a directory the walk could not descend into by its +// path in the lifeboat. The raw open error names the path openWalkDir opened — +// the last component with its "/." suffix — which is not a path the lifeboat +// holds (iss-2609252004013212), so only the cause is kept from it. +func walkOpenRefusal(rel string, err error) error { + var pe *fs.PathError + if errors.As(err, &pe) { + err = pe.Err + } + return fmt.Errorf("lifeboat directory %q cannot be opened: %w", rel, err) +} + // readLifeboatFile reads one lifeboat file through the containment root behind // the trust guards: no symlink, regular file, size under maxEmbarkFileBytes. The // lifeboat is untrusted, so the read goes through fsutil.ReadGuardedInRoot, which diff --git a/internal/core/lifeboat/embark_walk_hardening_test.go b/internal/core/lifeboat/embark_walk_hardening_test.go index 935b4c554..e2010dbe5 100644 --- a/internal/core/lifeboat/embark_walk_hardening_test.go +++ b/internal/core/lifeboat/embark_walk_hardening_test.go @@ -114,3 +114,39 @@ func TestWalkLifeboatFilesReturnsRegularFilesWithinBounds(t *testing.T) { t.Fatalf("walk = %q, want the three regular files sorted", got) } } + +// TestWalkLifeboatFilesNamesTheEntryItCannotOpen is iss-2609252004013212: a +// directory the descent cannot open refused with the raw open error, whose path +// carries the "/." suffix openWalkDir adds and names only the last component, so +// the refusal named a path that does not exist. It names the entry by its path +// in the lifeboat instead. An unreadable directory stands in for the swapped-in +// FIFO the suffix exists to refuse, because both fail the same open. +func TestWalkLifeboatFilesNamesTheEntryItCannotOpen(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root opens a directory whatever its mode") + } + dir := t.TempDir() + writeTree(t, dir, map[string]string{"sub/locked/a.txt": "x\n"}) + locked := filepath.Join(dir, "sub", "locked") + if err := os.Chmod(locked, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + + _, err = walkLifeboatFilesBounded(root, 1000, 50, 64) + if err == nil { + t.Fatal("an unopenable lifeboat directory was walked as if it were readable") + } + msg := err.Error() + if !strings.Contains(msg, `"sub/locked"`) { + t.Errorf("the refusal must name the entry by its lifeboat path, got %q", msg) + } + if strings.Contains(msg, "/.") { + t.Errorf("the refusal leaks the descent's internal \"/.\" suffix: %q", msg) + } +} diff --git a/internal/core/lifeboat/graveyard_lessons.go b/internal/core/lifeboat/graveyard_lessons.go index 8afd78648..8093a380e 100644 --- a/internal/core/lifeboat/graveyard_lessons.go +++ b/internal/core/lifeboat/graveyard_lessons.go @@ -12,6 +12,7 @@ import ( "strings" "syscall" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/update" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" @@ -80,7 +81,9 @@ func IngestLessons(lifeboatDir string, raw []byte) (LessonsResult, error) { dec.DisallowUnknownFields() // reject smuggled extra fields var lf LessonsFile if err := dec.Decode(&lf); err != nil { - return LessonsResult{}, fmt.Errorf("malformed lessons JSON: %v", err) + // The decoder names an undeclared field by the payload's own key: + // redacted, never raw (iss-2609290218032954). + return LessonsResult{}, fmt.Errorf("malformed lessons JSON: %s", scanner.RedactRefusal(abs, err.Error())) } if lf.SchemaVersion == 0 { return LessonsResult{}, errors.New("lessons payload is missing schema_version") diff --git a/internal/core/lifeboat/review_refusal_echo_test.go b/internal/core/lifeboat/review_refusal_echo_test.go new file mode 100644 index 000000000..96457c066 --- /dev/null +++ b/internal/core/lifeboat/review_refusal_echo_test.go @@ -0,0 +1,41 @@ +package lifeboat + +import ( + "strings" + "testing" +) + +// TestReviewLifeboatRefusalsDoNotEchoTheValue — iss-2609290033521472. The +// delegated review payload is host-composed, and its closed-set fields — the +// mode, the prompt_version and the verdict — were quoted into the refusal with +// a bare %q, so a token or a home path pasted into one reached the terminal and +// the transcript verbatim. Each is described now, never quoted. +func TestReviewLifeboatRefusalsDoNotEchoTheValue(t *testing.T) { + const leak = "zzleak-7f3a /Users/zzotherperson/notes" // abcd-lint:allow — a planted home path the refusal must not echo + good := string(reviewPayloadJSON("SHIP", `{"id":"fnd-ok","finding":"fine","evidence":["coverage.json"]}`)) + cases := map[string]string{ + "mode": strings.Replace(good, `"mode":"delegated"`, `"mode":"`+leak+`"`, 1), + "prompt_version": strings.Replace(good, `"prompt_version":"0.1.0"`, `"prompt_version":"`+leak+`"`, 1), + "verdict": strings.Replace(good, `"verdict":"SHIP"`, `"verdict":"`+leak+`"`, 1), + } + for field, payload := range cases { + t.Run(field, func(t *testing.T) { + if payload == good { + t.Fatalf("fixture: the %s field was not replaced", field) + } + dir := reviewFixture(t, "abc", &Summary{Grounded: 7, Blank: 3}) + _, err := ReviewLifeboat(dir, realSourceDir(t), []byte(payload)) + if err == nil { + t.Fatalf("an out-of-set %s was accepted", field) + } + for _, part := range []string{"zzleak-7f3a", "zzotherperson"} { + if strings.Contains(err.Error(), part) { + t.Errorf("the refusal echoes the refused %s: %v", field, err) + } + } + if !strings.Contains(err.Error(), field) { + t.Errorf("the refusal no longer names the field %s: %v", field, err) + } + }) + } +} diff --git a/internal/core/lifeboat/synthesis_pressrelease.go b/internal/core/lifeboat/synthesis_pressrelease.go index 1700bc5ca..cde0d34b0 100644 --- a/internal/core/lifeboat/synthesis_pressrelease.go +++ b/internal/core/lifeboat/synthesis_pressrelease.go @@ -27,6 +27,7 @@ import ( "os" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -68,7 +69,7 @@ func ComposePressRelease(lifeboatDir string, raw []byte) (PressReleaseResult, er if raw == nil { file = deterministicPressRelease(abs, paths) } else { - file, err = validateDelegatedPressRelease(raw, paths) + file, err = validateDelegatedPressRelease(abs, raw, paths) if err != nil { return PressReleaseResult{}, err } @@ -146,7 +147,7 @@ func deterministicPressRelease(abs string, paths map[string]bool) PressReleaseFi // schema/mode/prompt_version) are fatal; evidence resolving to nothing in the // restricted packed-path set is the whole-document refusal (ErrPressReleaseUncited). // Prose fields are sanitised and capped; the document is written whole on success. -func validateDelegatedPressRelease(raw []byte, paths map[string]bool) (PressReleaseFile, error) { +func validateDelegatedPressRelease(abs string, raw []byte, paths map[string]bool) (PressReleaseFile, error) { if len(raw) > maxSynthesisBytes { return PressReleaseFile{}, fmt.Errorf("press-release payload exceeds the %d-byte cap", maxSynthesisBytes) } @@ -154,7 +155,9 @@ func validateDelegatedPressRelease(raw []byte, paths map[string]bool) (PressRele dec.DisallowUnknownFields() var pf PressReleaseFile if err := dec.Decode(&pf); err != nil { - return PressReleaseFile{}, fmt.Errorf("malformed press-release JSON: %v", err) + // The decoder names an undeclared field by the payload's own key: + // redacted, never raw (iss-2609290218032954). + return PressReleaseFile{}, fmt.Errorf("malformed press-release JSON: %s", scanner.RedactRefusal(abs, err.Error())) } if err := synthSchemaGate("press-release", pf.SchemaVersion, PressReleaseSchemaVersion); err != nil { return PressReleaseFile{}, err diff --git a/internal/core/lifeboat/synthesis_principles.go b/internal/core/lifeboat/synthesis_principles.go index c94bfc76f..88e43ef73 100644 --- a/internal/core/lifeboat/synthesis_principles.go +++ b/internal/core/lifeboat/synthesis_principles.go @@ -32,6 +32,7 @@ import ( "sort" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/update" @@ -214,7 +215,9 @@ func validateDelegatedPrinciples(abs string, raw []byte) ([]Principle, string, [ dec.DisallowUnknownFields() var pf PrinciplesFile if err := dec.Decode(&pf); err != nil { - return nil, "", nil, fmt.Errorf("malformed principles JSON: %v", err) + // The decoder names an undeclared field by the payload's own key: + // redacted, never raw (iss-2609290218032954). + return nil, "", nil, fmt.Errorf("malformed principles JSON: %s", scanner.RedactRefusal(abs, err.Error())) } if err := synthSchemaGate("principles", pf.SchemaVersion, PrinciplesSchemaVersion); err != nil { return nil, "", nil, err diff --git a/internal/core/lifeboat/synthesis_review.go b/internal/core/lifeboat/synthesis_review.go index 8800f1cea..1f3a5b11e 100644 --- a/internal/core/lifeboat/synthesis_review.go +++ b/internal/core/lifeboat/synthesis_review.go @@ -43,8 +43,10 @@ import ( "sort" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/update" "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" ) // reviewArtefactDir is where the verdict artefact lives; legacyReviewDir is the @@ -271,7 +273,9 @@ func validateReview(abs string, raw []byte) (ReviewVerdict, []ReviewFinding, rev dec.DisallowUnknownFields() // reject smuggled extra fields var in ReviewArtefact if err := dec.Decode(&in); err != nil { - return "", nil, rep, fmt.Errorf("malformed review JSON: %v", err) + // The decoder names an undeclared field by the payload's own key: + // redacted, never raw (iss-2609290218032954). + return "", nil, rep, fmt.Errorf("malformed review JSON: %s", scanner.RedactRefusal(abs, err.Error())) } // Three-branch schema gate (mirrors IngestLessons). if in.SchemaVersion == 0 { @@ -287,16 +291,16 @@ func validateReview(abs string, raw []byte) (ReviewVerdict, []ReviewFinding, rev // Mode gate: a delegated payload must not claim deterministic. An absent mode is // allowed (the core stamps delegated on write regardless). if in.Mode != "" && in.Mode != ModeDelegated { - return "", nil, rep, fmt.Errorf("a delegated payload must not claim mode %q", in.Mode) + return "", nil, rep, fmt.Errorf("a delegated payload must not claim mode (%s)", termsafe.DescribeRefused(string(in.Mode))) } // prompt_version gate: required and semver-shaped in delegated mode. if !promptVersionRe.MatchString(in.PromptVersion) { - return "", nil, rep, fmt.Errorf("prompt_version %q is not semver-shaped", in.PromptVersion) + return "", nil, rep, fmt.Errorf("prompt_version (%s) is not semver-shaped", termsafe.DescribeRefused(in.PromptVersion)) } rep.promptVersion = in.PromptVersion // Whole-payload verdict membership gate. if !in.Verdict.Valid() { - return "", nil, rep, fmt.Errorf("out-of-enum verdict %q", in.Verdict) + return "", nil, rep, fmt.Errorf("out-of-enum verdict (%s)", termsafe.DescribeRefused(string(in.Verdict))) } if len(in.Findings) > maxReviewFindings { return "", nil, rep, fmt.Errorf("too many findings (%d > %d)", len(in.Findings), maxReviewFindings) diff --git a/internal/core/lint/autoreleasedetect_test.go b/internal/core/lint/autoreleasedetect_test.go index 4cca13165..f21c3465f 100644 --- a/internal/core/lint/autoreleasedetect_test.go +++ b/internal/core/lint/autoreleasedetect_test.go @@ -44,6 +44,7 @@ case "$sub" in if [ -f "$FAKE_DIR/list-fails" ]; then echo "gh: Resource not accessible by integration (HTTP 403)" >&2; exit 1; fi jq -r "${expr:-.}" "$FAKE_DIR/runs.json" ;; "run view") + if [ -f "$FAKE_DIR/view-fails" ]; then echo "gh: HTTP 502: Server Error" >&2; exit 1; fi [ -f "$FAKE_DIR/run-$id.json" ] || { echo "fake gh: no run $id" >&2; exit 95; } jq -r "${expr:-.}" "$FAKE_DIR/run-$id.json" ;; *) echo "fake gh: $sub is not faked" >&2; exit 99 ;; @@ -55,8 +56,9 @@ type detectCase struct { tagged bool // the newest CHANGELOG version is tagged released bool // its GitHub Release exists runs []string // release.yml push-run ids for the tag, newest first - verify map[string]string // run id -> the conclusion of that run's verify job + verify map[string]string // run id -> the conclusion of that run's verify job ("in_progress": still running, no conclusion) listFail bool // the run listing errors + viewFail bool // reading a run's jobs errors } // runDetect runs one workflow's detect script in a scratch repository shaped @@ -99,11 +101,19 @@ func runDetect(t *testing.T, workflow string, c detectCase) (string, int, string if c.listFail { write("list-fails", "") } + if c.viewFail { + write("view-fails", "") + } var items []string for _, id := range c.runs { items = append(items, `{"databaseId":`+id+`}`) - write("run-"+id+".json", `{"jobs":[{"name":"verify","conclusion":"`+c.verify[id]+ - `"},{"name":"tag","conclusion":"skipped"},{"name":"release","conclusion":"failure"}]}`) + // The API reports a job that has not finished with a null conclusion. + verify := `"status":"completed","conclusion":"` + c.verify[id] + `"` + if c.verify[id] == "in_progress" { + verify = `"status":"in_progress","conclusion":null` + } + write("run-"+id+".json", `{"jobs":[{"name":"verify",`+verify+ + `},{"name":"tag","conclusion":"skipped"},{"name":"release","conclusion":"failure"}]}`) } write("runs.json", "["+strings.Join(items, ",")+"]") @@ -205,6 +215,30 @@ func TestAutoReleaseRefusesToRebuildATagItsVerifyRefused(t *testing.T) { t.Errorf("detect guessed past a failed run listing (rc=%d):\n%s\n%s", code, out, got) } }) + // iss-2609251616310535: the two remaining reads of the step. A run + // whose jobs cannot be read is refused like a failed listing, and a + // verify still running has not refused the tag, so it heals. + t.Run(where+": a failed read of the run's jobs refuses loudly", func(t *testing.T) { + out, code, got := runDetect(t, wf, detectCase{ + tagged: true, runs: []string{"802"}, + verify: map[string]string{"802": "success"}, viewFail: true, + }) + if code == 0 || strings.Contains(got, "need_release=true") { + t.Errorf("detect guessed past a failed read of run 802's jobs (rc=%d):\n%s\n%s", code, out, got) + } + if !strings.Contains(out, "802") { + t.Errorf("the refusal must name the run whose jobs it could not read:\n%s", out) + } + }) + t.Run(where+": a verify still in progress heals", func(t *testing.T) { + out, code, got := runDetect(t, wf, detectCase{ + tagged: true, runs: []string{"802"}, + verify: map[string]string{"802": "in_progress"}, + }) + if code != 0 || !strings.Contains(got, "need_release=true") || !strings.Contains(got, "release_ref=") { + t.Errorf("detect refused a tag whose verify has not concluded (rc=%d):\n%s\n%s", code, out, got) + } + }) t.Run(where+": a released tag does nothing", func(t *testing.T) { out, code, got := runDetect(t, wf, detectCase{ tagged: true, released: true, runs: []string{"802"}, diff --git a/internal/core/memory/ask.go b/internal/core/memory/ask.go index 835f77305..de6701840 100644 --- a/internal/core/memory/ask.go +++ b/internal/core/memory/ask.go @@ -407,7 +407,7 @@ func fileBack(root string, matches []MatchedPage, rawPage map[string]any, decide merged["source"] = src rawPage = merged } - page, err := ValidateDistilledPage(rawPage) + page, err := ValidateDistilledPage(root, rawPage) if err != nil { return FileBackResult{}, err } diff --git a/internal/core/memory/ingest.go b/internal/core/memory/ingest.go index 9fdea18ad..795cffd0b 100644 --- a/internal/core/memory/ingest.go +++ b/internal/core/memory/ingest.go @@ -283,7 +283,7 @@ func Ingest(req IngestRequest) (IngestResult, error) { merged["source"] = sourceBlock raw = merged } - page, err := ValidateDistilledPage(raw) + page, err := ValidateDistilledPage(root, raw) if err != nil { return IngestResult{}, err } @@ -292,9 +292,12 @@ func Ingest(req IngestRequest) (IngestResult, error) { if len(distilled) == 0 { return IngestResult{}, newIngestError("distillation produced 0 pages for %s; nothing written", material.origin) } - for _, page := range distilled { + for i, page := range distilled { if !contains(SourceHashes(page.Source), contentHash) { - return IngestResult{}, newIngestError("distilled page %s does not cite the ingested source hash %s; refusing to write an unattributable page", page.Filename(), contentHash) + // The page is named by its position, not its filename: the slug is + // host-chosen and slugRe admits a token's characters + // (iss-2609290300464268). + return IngestResult{}, newIngestError("distilled page %d of %d does not cite the ingested source hash %s; refusing to write an unattributable page", i+1, len(distilled), contentHash) } } diff --git a/internal/core/memory/memory_test.go b/internal/core/memory/memory_test.go index d975c4570..1e7fd4dbf 100644 --- a/internal/core/memory/memory_test.go +++ b/internal/core/memory/memory_test.go @@ -752,7 +752,7 @@ func TestIngestRefusesSSRFTargets(t *testing.T) { // --------------------------------------------------------------------------- func TestValidateDistilledPageRejectsSuppliedTopicHash(t *testing.T) { - _, err := ValidateDistilledPage(map[string]any{ + _, err := ValidateDistilledPage(t.TempDir(), map[string]any{ "type": "topic", "domain": "auth", "slug": "x", "body": "# b", "source": map[string]any{"class": "session_memory"}, "topic_hash": strings.Repeat("a", 64), @@ -763,7 +763,7 @@ func TestValidateDistilledPageRejectsSuppliedTopicHash(t *testing.T) { } func TestValidateDistilledPageComputesTopicHash(t *testing.T) { - p, err := ValidateDistilledPage(map[string]any{ + p, err := ValidateDistilledPage(t.TempDir(), map[string]any{ "type": "topic", "domain": "auth", "slug": "x", "body": "# Subject line", "source": map[string]any{"class": "session_memory"}, }) diff --git a/internal/core/memory/redact.go b/internal/core/memory/redact.go index 8db8461ca..68f57eecf 100644 --- a/internal/core/memory/redact.go +++ b/internal/core/memory/redact.go @@ -7,6 +7,7 @@ import ( "unicode/utf8" "github.com/intentdriven/abcd/internal/adapter/scanner" + "github.com/intentdriven/abcd/internal/termsafe" ) // redact.go — the write-time secret/PII sanitiser for the committed @@ -326,12 +327,55 @@ func (r *storeRedactor) judgeKey(key, label string) error { // joined form hides in the scanner exactly the token the slug carries plainly. // The underscore SUFFIXES are judged for the same reason carried one step // further — see filenameJudgeTexts. +// +// The refusal names the page with every refused span sealed (sealedFilename): +// the name is what the operator repairs, and the span is what must not reach +// the terminal or the transcript (iss-2609290411321963). func (r *storeRedactor) judgeFilename(filename string) error { kinds := r.filenameHardFailKinds(filename) if len(kinds) == 0 { return nil } - return newIngestError("refusing to write %s: the page filename carries %d hard-fail span(s) [%s]; a page name cannot be redacted without renaming the page the store resolves, so repair the slug at the source", filename, len(kinds), strings.Join(kinds, ", ")) + return newIngestError("refusing to write %s: the page filename carries %d hard-fail span(s) [%s]; a page name cannot be redacted without renaming the page the store resolves, so repair the slug at the source", r.sealedFilename(filename), len(kinds), strings.Join(kinds, ", ")) +} + +// sealedFilename is filename with every span a hard_fail finding over +// filenameJudgeTexts matched replaced by `[sealed]`, so a refusal can name the +// page without quoting the secret it refuses. The spans are marked byte by byte +// in the joined name and each marked run is sealed once, so two overlapping +// matches cannot leave a raw tail. A match that cannot be located in the name +// (a finding read through a decoded view) leaves the whole name described, +// never echoed. +func (r *storeRedactor) sealedFilename(filename string) string { + mask := make([]bool, len(filename)) + for _, text := range filenameJudgeTexts(filename) { + for _, f := range r.hardFailResidue(text, filename) { + if f.Matched == "" || !strings.Contains(filename, f.Matched) { + return termsafe.DescribeRefused(filename) + } + for from := 0; ; { + i := strings.Index(filename[from:], f.Matched) + if i < 0 { + break + } + for j := from + i; j < from+i+len(f.Matched); j++ { + mask[j] = true + } + from += i + 1 + } + } + } + var b strings.Builder + for i := 0; i < len(filename); i++ { + if !mask[i] { + b.WriteByte(filename[i]) + continue + } + if i == 0 || !mask[i-1] { + b.WriteString("[sealed]") + } + } + return b.String() } // filenameHardFailKinds is the page-name verdict itself: the distinct hard_fail diff --git a/internal/core/memory/schema.go b/internal/core/memory/schema.go index 3fa0a0f01..835f806ad 100644 --- a/internal/core/memory/schema.go +++ b/internal/core/memory/schema.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/termsafe" ) @@ -148,10 +149,35 @@ func deriveClasses(sources []any) []string { return seen } +// describeValue renders a refused payload value for a schema error: described by +// its length, never quoted, since the payload is host-produced and a token or a +// home path in it would reach the terminal and the transcript +// (iss-2609290300464268). +func describeValue(value any) string { + if s, ok := value.(string); ok { + return termsafe.DescribeRefused(s) + } + return "a non-string value" +} + +// describeClasses renders a declared source.classes list for a schema error: an +// entry in the closed enum is quoted, any other is described. +func describeClasses(declared []string) string { + out := make([]string, 0, len(declared)) + for _, c := range declared { + if memorySourceClasses[c] { + out = append(out, c) + } else { + out = append(out, termsafe.DescribeRefused(c)) + } + } + return "[" + strings.Join(out, ", ") + "]" +} + func requireClass(value any, where string) (string, error) { s, ok := value.(string) if !ok || !memorySourceClasses[s] { - return "", newSchemaError("%s: source class must be one of the closed enum, got %v", where, value) + return "", newSchemaError("%s: source class must be one of the closed enum, got %s", where, describeValue(value)) } return s, nil } @@ -167,7 +193,7 @@ func requireCitation(value any, where string) error { func requireDate(value any, where string) error { s, ok := value.(string) if !ok || !dateRe.MatchString(s) { - return newSchemaError("%s: ingested_at must be YYYY-MM-DD, got %v", where, value) + return newSchemaError("%s: ingested_at must be YYYY-MM-DD, got %s", where, describeValue(value)) } return nil } @@ -296,7 +322,9 @@ func validateSourceBlock(source any) error { } } if !ok || !equalStringSets(declaredStr, derived) { - return newSchemaError("source.classes must equal the set derived from each sources[].class (expected %v, got %v)", derived, declaredStr) + // derived passed requireClass entry by entry, so it is the closed enum; + // the declared list is payload and is quoted only where it is too. + return newSchemaError("source.classes must equal the set derived from each sources[].class (expected %v, got %s)", derived, describeClasses(declaredStr)) } if len(derived) >= 2 { note, ok := sm["weighting_note"].(string) @@ -410,7 +438,10 @@ func requireStrList(value any, key string) ([]string, error) { // ValidateDistilledPage validates one raw page dict against the DistilledPage // schema, computing topic_hash (a supplied one is rejected). The ingest path // runs this before any write. -func ValidateDistilledPage(data map[string]any) (DistilledPage, error) { +// +// repoRoot selects the canonical scanner configuration an undeclared key is +// redacted with when the page is refused. +func ValidateDistilledPage(repoRoot string, data map[string]any) (DistilledPage, error) { if data == nil { return DistilledPage{}, newSchemaError("DistilledPage must be a mapping") } @@ -422,7 +453,11 @@ func ValidateDistilledPage(data map[string]any) (DistilledPage, error) { } if len(unknown) > 0 { sort.Strings(unknown) - return DistilledPage{}, newSchemaError("DistilledPage carries unknown key(s) %v — the boundary fails closed on keys outside the schema", unknown) + // The keys are the payload's own spelling and the one thing the reader + // needs to find the fault, so they are named redacted, never raw + // (iss-2609290300464268). + return DistilledPage{}, newSchemaError("DistilledPage carries unknown key(s) [%s] — the boundary fails closed on keys outside the schema", + scanner.RedactRefusal(repoRoot, strings.Join(unknown, " "))) } for _, k := range distilledPageRequired { if _, ok := data[k]; !ok { @@ -433,13 +468,13 @@ func ValidateDistilledPage(data map[string]any) (DistilledPage, error) { domain, _ := data["domain"].(string) slug, _ := data["slug"].(string) if !typeDomainRe.MatchString(typ) { - return DistilledPage{}, newSchemaError("DistilledPage.type must be a filename-safe token, got %v", data["type"]) + return DistilledPage{}, newSchemaError("DistilledPage.type must be a filename-safe token, got %s", describeValue(data["type"])) } if !typeDomainRe.MatchString(domain) { - return DistilledPage{}, newSchemaError("DistilledPage.domain must be a filename-safe token, got %v", data["domain"]) + return DistilledPage{}, newSchemaError("DistilledPage.domain must be a filename-safe token, got %s", describeValue(data["domain"])) } if !slugRe.MatchString(slug) { - return DistilledPage{}, newSchemaError("DistilledPage.slug must be a filename-safe token, got %v", data["slug"]) + return DistilledPage{}, newSchemaError("DistilledPage.slug must be a filename-safe token, got %s", describeValue(data["slug"])) } body, ok := data["body"].(string) if !ok || strings.TrimSpace(body) == "" { @@ -488,7 +523,7 @@ func ValidateDistilledPage(data map[string]any) (DistilledPage, error) { Recall: recall, } if _, _, _, ok := ParsePageFilename(page.Filename()); !ok || !IsMemoryPageName(page.Filename()) { - return DistilledPage{}, newSchemaError("DistilledPage assembles an unwritable filename: %q", page.Filename()) + return DistilledPage{}, newSchemaError("DistilledPage assembles an unwritable filename, %s", termsafe.DescribeRefused(page.Filename())) } return page, nil } @@ -528,7 +563,7 @@ func uniqueFilename(page DistilledPage, taken map[string]bool) (string, error) { return candidate, nil } } - return "", newSchemaError("cannot derive a free fork filename for %q", page.Filename()) + return "", newSchemaError("cannot derive a free fork filename for a page named by %s", termsafe.DescribeRefused(page.Filename())) } // ResolveDistilledPages is the pure cross-ref dedup owner (link / fork+contradiction diff --git a/internal/core/memory/schema_refusal_echo_test.go b/internal/core/memory/schema_refusal_echo_test.go new file mode 100644 index 000000000..5a0047ae7 --- /dev/null +++ b/internal/core/memory/schema_refusal_echo_test.go @@ -0,0 +1,101 @@ +package memory + +import ( + "strings" + "testing" +) + +// memoryLeak is a payload value no refusal may carry back: a marker and a third +// party's absolute home path. +const memoryLeak = "zzleak-7f3a /Users/zzotherperson/notes" // abcd-lint:allow — a planted home path the refusal must not echo + +// TestPageSchemaRefusalsDoNotEchoThePayload — iss-2609290300464268. A +// DistilledPage arrives host-produced (memory ingest --pages-json, memory ask +// --page-json), and the schema boundary quoted a refused source class, an +// ingested_at, the declared classes, the undeclared keys, and the type, domain +// and slug with %v or %q, never redacted, so a token or a home path in any of +// them reached the terminal and the transcript. A value is described now, a +// declared class is quoted only when it is in the closed enum, and an undeclared +// key is named through the canonical redactor. +func TestPageSchemaRefusalsDoNotEchoThePayload(t *testing.T) { + page := func(mut func(p map[string]any)) map[string]any { + p := map[string]any{ + "type": "topic", "domain": "auth", "slug": "x", "body": "# Subject line", + "source": map[string]any{"class": "session_memory"}, + } + mut(p) + return p + } + entry := func(class string) map[string]any { + return map[string]any{ + "class": class, "citation": map[string]any{"title": "t"}, "licence": "unknown", + "source_hash": strings.Repeat("a", 64), "ingested_at": "2026-09-01", + } + } + cases := []struct { + name, names string + data map[string]any + }{ + {"source class", "source class", page(func(p map[string]any) { + p["source"] = map[string]any{"class": memoryLeak} + })}, + {"ingested_at", "ingested_at", page(func(p map[string]any) { + p["source"] = map[string]any{"class": "session_memory", "ingested_at": memoryLeak} + })}, + {"declared classes", "source.classes", page(func(p map[string]any) { + p["source"] = map[string]any{ + "classes": []any{"session_memory", memoryLeak}, + "sources": []any{entry("session_memory")}, + } + })}, + {"undeclared key", "reviewer_notes", page(func(p map[string]any) { + p["reviewer_notes "+"/Users/zzotherperson/notes"] = 1 // abcd-lint:allow — a planted home path in a KEY + })}, + {"type", "DistilledPage.type", page(func(p map[string]any) { p["type"] = memoryLeak })}, + {"domain", "DistilledPage.domain", page(func(p map[string]any) { p["domain"] = memoryLeak })}, + {"slug", "DistilledPage.slug", page(func(p map[string]any) { p["slug"] = memoryLeak })}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateDistilledPage(t.TempDir(), tc.data) + if err == nil { + t.Fatal("a page carrying the leak was accepted") + } + for _, part := range []string{"zzleak-7f3a", "zzotherperson"} { + if strings.Contains(err.Error(), part) { + t.Errorf("the refusal echoes the payload (%q): %v", part, err) + } + } + if !strings.Contains(err.Error(), tc.names) { + t.Errorf("the refusal no longer names %s: %v", tc.names, err) + } + }) + } +} + +// TestUncitedPageRefusalDoesNotEchoTheFilename — found by the sweep for +// iss-2609290300464268. A distilled page that does not cite the ingested source +// was refused naming page.Filename(), which carries the host-chosen slug; slugRe +// admits a token's characters, so a token-shaped slug was echoed whole. The page +// is named by its position in the distiller's output instead. +func TestUncitedPageRefusalDoesNotEchoTheFilename(t *testing.T) { + repo := t.TempDir() + token, _ := x46mSpans(t) + src := writeSource(t, repo, "notes.md", "Rotate tokens every 24 hours.\n") + distiller := func(_ string, _ map[string]any) ([]map[string]any, error) { + return []map[string]any{{ + "type": "topic", "domain": "auth", "slug": token, "body": "# Token rotation\nRotate.\n", + "source": map[string]any{"class": "session_memory"}, + }}, nil + } + _, err := Ingest(IngestRequest{RepoRoot: repo, Source: src, Distiller: distiller, Now: fixedNow}) + if err == nil { + t.Fatal("a page that does not cite the ingested source was accepted") + } + if strings.Contains(err.Error(), token) { + t.Errorf("the refusal echoes the page's token-shaped slug: %v", err) + } + if !strings.Contains(err.Error(), "does not cite the ingested source hash") || !strings.Contains(err.Error(), "page 1") { + t.Errorf("the refusal no longer locates the page: %v", err) + } +} diff --git a/internal/core/memory/single_source_required_fields_test.go b/internal/core/memory/single_source_required_fields_test.go index e9c4bae34..c5ddd6101 100644 --- a/internal/core/memory/single_source_required_fields_test.go +++ b/internal/core/memory/single_source_required_fields_test.go @@ -84,7 +84,7 @@ func TestValidateSingleSourceRequiresExternalProvenance(t *testing.T) { // the reachable path: `ask --file-back --page-json` skips fileBackSource // (which demands all five fields) whenever the supplied page carries its own // source block, handing it straight to ValidateDistilledPage. - if _, err := ValidateDistilledPage(map[string]any{ + if _, err := ValidateDistilledPage(t.TempDir(), map[string]any{ "type": "topic", "domain": "auth", "slug": "x", "body": "# Subject line", "source": map[string]any{ "class": "external_pdf", "citation": citation, "licence": "CC-BY-4.0", "ingested_at": "2026-08-19", @@ -92,7 +92,7 @@ func TestValidateSingleSourceRequiresExternalProvenance(t *testing.T) { }); err == nil { t.Fatal("ValidateDistilledPage accepted an external_pdf page with no source_hash") } - if _, err := ValidateDistilledPage(map[string]any{ + if _, err := ValidateDistilledPage(t.TempDir(), map[string]any{ "type": "topic", "domain": "auth", "slug": "x", "body": "# Subject line", "source": full(), }); err != nil { diff --git a/internal/core/memory/writer_filename_test.go b/internal/core/memory/writer_filename_test.go index 216e2820a..0d422a485 100644 --- a/internal/core/memory/writer_filename_test.go +++ b/internal/core/memory/writer_filename_test.go @@ -76,9 +76,13 @@ func TestWriteRefusesASecretShapedFilename(t *testing.T) { } // Unlike judgeKey, the refusal names the page: the filename is the write's // identity, and a batch refusal that withheld it would leave the operator - // with no way to say which page to repair. - if !strings.Contains(err.Error(), page) { - t.Errorf("the refusal does not name the refused page %q: %v", page, err) + // with no way to say which page to repair. The token itself is sealed, so + // the refusal does not echo what it refuses (iss-2609290411321963). + if sealed := strings.Replace(page, token, "[sealed]", 1); !strings.Contains(err.Error(), sealed) { + t.Errorf("the refusal does not name the refused page as %q: %v", sealed, err) + } + if strings.Contains(err.Error(), token) { + t.Errorf("the refusal echoes the token it refuses: %v", err) } mem := Dir(repo) @@ -228,8 +232,11 @@ func TestWriteRefusesACredentialSplitAcrossTheSeparator(t *testing.T) { if err == nil { t.Fatalf("a page FILENAME spelling %s across the separator was accepted into the store", tc.token) } - if !strings.Contains(err.Error(), page) { - t.Errorf("the refusal does not name the refused page %q: %v", page, err) + if sealed := strings.Replace(page, tc.token, "[sealed]", 1); !strings.Contains(err.Error(), sealed) { + t.Errorf("the refusal does not name the refused page as %q: %v", sealed, err) + } + if strings.Contains(err.Error(), tc.token) { + t.Errorf("the refusal echoes the token it refuses: %v", err) } mem := Dir(repo) diff --git a/internal/core/reading/ingest.go b/internal/core/reading/ingest.go index fa1b9bcc9..f5a73b2f3 100644 --- a/internal/core/reading/ingest.go +++ b/internal/core/reading/ingest.go @@ -575,7 +575,7 @@ func ingestUnderLock(root *os.Root, repoRoot string, req IngestRequest, res *Ing return err } } - out, err := decodeOutput(raw) + out, err := decodeOutput(raw, repoRoot) if err != nil { return err } @@ -587,7 +587,7 @@ func ingestUnderLock(root *os.Root, repoRoot string, req IngestRequest, res *Ing res.RunID = out.RunID res.Position = pos - manifest, err := resolveParkedManifest(root, out) + manifest, err := resolveParkedManifest(root, repoRoot, out) if err != nil { return err } @@ -745,12 +745,16 @@ func readOutputFile(path string) ([]byte, error) { // decodeOutput decodes the payload strictly. Unknown fields are refused at every // declared level, and trailing content after the document is refused too: a // second document appended to the first is a payload nobody has read. -func decodeOutput(raw []byte) (Output, error) { +// +// The decoder's message names an undeclared field by the payload's own key, and +// that name is what the reader needs to find the fault, so it is redacted rather +// than described (iss-2609290043245353). +func decodeOutput(raw []byte, repoRoot string) (Output, error) { dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() var out Output if err := dec.Decode(&out); err != nil { - return Output{}, fmt.Errorf("reading: the output is malformed: %s", echo(err.Error())) + return Output{}, fmt.Errorf("reading: the output is malformed: %s", redactRefused(repoRoot, err.Error())) } if dec.More() { return Output{}, errors.New("reading: the output carries trailing content after the document") @@ -761,23 +765,29 @@ func decodeOutput(raw []byte) (Output, error) { // checkEnvelope validates the run-level fields that must hold before any path is // built or any file is opened. The run id is checked FIRST among the values a // path is built from, because it is the only payload value that ever becomes one. +// +// Each of these fields has a closed shape, so a refused value is DESCRIBED and +// never quoted: the field's name says where the fault is, and the value itself +// is only ever a token or a path pasted into the wrong place, which would reach +// the terminal and the transcript unredacted (iss-2609290043245353). func checkEnvelope(out Output) (Position, error) { if out.Type != OutputType { - return "", fmt.Errorf("reading: the output states _type %q, want %q", echo(out.Type), OutputType) + return "", fmt.Errorf("reading: the output states _type as %s, want %q", + termsafe.DescribeRefused(out.Type), OutputType) } if !recordid.ValidReadingRunID(out.RunID) { - return "", fmt.Errorf("reading: run_id %q is not a run identifier (%s-N); "+ + return "", fmt.Errorf("reading: run_id is %s, which is not a run identifier (%s-N); "+ "an ingest names the run an assembly parked, and a run id becomes a directory name", - echo(out.RunID), RunIDFamily) + termsafe.DescribeRefused(out.RunID), RunIDFamily) } - // The parser quotes the token it refused, and that token is payload text, so - // the whole message goes through echo rather than the value alone. pos, err := ParsePosition(string(out.Position)) if err != nil { - return "", fmt.Errorf("reading: %s", echo(err.Error())) + return "", fmt.Errorf("reading: position is %s, which is not a reading position; the set is closed: %s", + termsafe.DescribeRefused(string(out.Position)), sortedPositions(Positions())) } if !sha256HexRe.MatchString(out.ManifestSHA256) { - return "", fmt.Errorf("reading: manifest_sha256 %q is not a sha-256 digest", echo(out.ManifestSHA256)) + return "", fmt.Errorf("reading: manifest_sha256 is %s, which is not a sha-256 digest", + termsafe.DescribeRefused(out.ManifestSHA256)) } if isBlank(out.Instrument.Model) || isBlank(out.Instrument.DefinitionSHA256) || isBlank(out.Instrument.AssemblerVersion) { @@ -809,7 +819,7 @@ var sha256HexRe = regexp.MustCompile(`^[0-9a-f]{64}$`) // unforgeable reference, because it cannot be asserted without the bytes. A // reference that resolves to nothing, or to a manifest whose hash disagrees, // refuses the run. -func resolveParkedManifest(root *os.Root, out Output) (Manifest, error) { +func resolveParkedManifest(root *os.Root, repoRoot string, out Output) (Manifest, error) { // out.RunID has already been matched against the run-id grammar, which makes // it a single safe path COMPONENT: it holds no separator and no dot. That // says nothing about the components above it, so the read is resolved through @@ -827,7 +837,10 @@ func resolveParkedManifest(root *os.Root, out Output) (Manifest, error) { } m, err := DecodeManifest(raw) if err != nil { - return Manifest{}, fmt.Errorf("reading: the manifest of run %s: %w", out.RunID, err) + // The parked manifest is rewritable by the session that answers it, and + // the decoder names an undeclared key by its spelling: redacted, never + // raw (iss-2609290218032954). + return Manifest{}, fmt.Errorf("reading: the manifest of run %s: %s", out.RunID, redactRefused(repoRoot, err.Error())) } if got := sha256Hex(raw); got != out.ManifestSHA256 { return Manifest{}, fmt.Errorf("reading: manifest_sha256 is %s, and the manifest parked at %s hashes "+ @@ -956,8 +969,8 @@ func WriteRunArtefact(repoRoot, runID, name string, v any) (string, error) { return "", errors.New("reading: writing a run artefact needs a repository root") } if !recordid.ValidReadingRunID(runID) { - return "", fmt.Errorf("reading: run %q is not a run identifier (%s-N); a run id becomes a "+ - "directory name", echo(runID), RunIDFamily) + return "", fmt.Errorf("reading: run is %s, which is not a run identifier (%s-N); a run id "+ + "becomes a directory name", termsafe.DescribeRefused(runID), RunIDFamily) } if err := validArtefactName(name); err != nil { return "", err diff --git a/internal/core/reading/ingest_refusal_echo_test.go b/internal/core/reading/ingest_refusal_echo_test.go new file mode 100644 index 000000000..f398deef5 --- /dev/null +++ b/internal/core/reading/ingest_refusal_echo_test.go @@ -0,0 +1,146 @@ +package reading + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// refusalLeak is a payload value no refusal may carry back: a marker and a +// third party's absolute home path, the shape a token or a path pasted into the +// wrong field takes. +const refusalLeak = "zzleak-7f3a /Users/zzotherperson/notes" // abcd-lint:allow — a planted home path the refusal must not echo + +// assertNoRefusalLeak fails when a refusal carries any part of refusalLeak, or +// no longer names the field it refuses. +func assertNoRefusalLeak(t *testing.T, err error, field string) { + t.Helper() + if err == nil { + t.Fatalf("a payload carrying the leak in %s was accepted", field) + } + for _, part := range []string{"zzleak-7f3a", "zzotherperson"} { + if strings.Contains(err.Error(), part) { + t.Errorf("the refusal echoes the refused %s: %v", field, err) + } + } + if !strings.Contains(err.Error(), field) { + t.Errorf("the refusal no longer names %s: %v", field, err) + } +} + +// TestEnvelopeRefusalsDoNotEchoThePayload — iss-2609290043245353. The envelope +// checks run before the run's identity is proven, and each returned the +// payload's own value through echo(), which cleans and caps but does not redact, +// so a token or a home path in a closed-shape field reached the terminal and the +// transcript. A closed-shape value is described now; the one refusal whose value +// the reader needs to find the fault — an undeclared field's NAME — is redacted +// through the canonical scanner and still named. +func TestEnvelopeRefusalsDoNotEchoThePayload(t *testing.T) { + for _, field := range []string{"_type", "run_id", "position", "manifest_sha256"} { + t.Run(field, func(t *testing.T) { + f := newIngestFixture(t, "detection") + doc := f.payload(1) + doc[field] = refusalLeak + _, err := f.ingest(doc) + assertNoRefusalLeak(t, err, field) + f.nothingDurable(f.runID) + }) + } + + t.Run("undeclared envelope field", func(t *testing.T) { + f := newIngestFixture(t, "detection") + doc := f.payload(1) + doc["reviewer_notes /Users/zzotherperson/notes"] = "x" // abcd-lint:allow — a planted home path in a KEY + _, err := f.ingest(doc) + if err == nil { + t.Fatal("an undeclared envelope field was accepted") + } + if strings.Contains(err.Error(), "zzotherperson") { + t.Errorf("the refusal echoes the home path in the undeclared field's name: %v", err) + } + if !strings.Contains(err.Error(), "reviewer_notes") { + t.Errorf("the refusal no longer names the undeclared field: %v", err) + } + }) +} + +// TestWriteRunArtefactDoesNotEchoARefusedRunID — the same class at the run +// artefact writer: a run id that is not one is described, never quoted. +func TestWriteRunArtefactDoesNotEchoARefusedRunID(t *testing.T) { + _, err := WriteRunArtefact(t.TempDir(), refusalLeak, "scribe-manifest.json", map[string]string{}) + assertNoRefusalLeak(t, err, "run") +} + +// TestUndeclaredFieldRefusalFailsClosedOnADegradedScanner — the refusal's +// redactor must consult the scanner's degraded state, as every write-time +// redactor does (iss-2609290043245353). A per-repo scanner config that does not +// parse leaves the scanner degraded; the undeclared field's name is then +// described, never echoed through a weakened pattern set. +func TestUndeclaredFieldRefusalFailsClosedOnADegradedScanner(t *testing.T) { + f := newIngestFixture(t, "detection") + cfg := filepath.Join(f.root, ".abcd", "config", "pii.json") + if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfg, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + doc := f.payload(1) + doc["reviewer_notes /Users/zzotherperson/notes"] = "x" // abcd-lint:allow — a planted home path in a KEY + _, err := f.ingest(doc) + if err == nil { + t.Fatal("an undeclared envelope field was accepted") + } + for _, part := range []string{"zzotherperson", "reviewer_notes"} { + if strings.Contains(err.Error(), part) { + t.Errorf("a degraded scanner let the refusal echo the decoder's message (%q): %v", part, err) + } + } + if !strings.Contains(err.Error(), "not quoted") { + t.Errorf("a degraded scanner did not describe the message: %v", err) + } +} + +// TestParkedManifestRefusalRedactsTheKey — iss-2609290218032954. The parked +// manifest sits in the local tier, where a reading session with tools can +// rewrite it (and re-point manifest_sha256 at the rewrite), so a key in it is +// payload-chosen too. The strict decoder names an undeclared key by that +// spelling, and the refusal returned it raw; it is named redacted now. +func TestParkedManifestRefusalRedactsTheKey(t *testing.T) { + f := newIngestFixture(t, "detection") + rel := DefaultRunDir + "/" + f.runID + "/" + ManifestFileName + raw, err := os.ReadFile(filepath.Join(f.root, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + planted := strings.Replace(string(raw), "{", `{"reviewer_notes /Users/zzotherperson/notes":1,`, 1) // abcd-lint:allow — a planted home path in a KEY + f.write(rel, []byte(planted)) + _, err = f.ingest(f.payload(1)) + if err == nil { + t.Fatal("a parked manifest carrying an undeclared key was accepted") + } + if strings.Contains(err.Error(), "zzotherperson") { + t.Errorf("the refusal echoes the parked manifest's key: %v", err) + } + if !strings.Contains(err.Error(), "reviewer_notes") { + t.Errorf("the refusal no longer names the undeclared field: %v", err) + } +} + +// TestRefusedKeySweepsTheCallerHome — iss-2609290043245353. redactRefused was a +// second copy of scanner.RedactRefusal without its literal sweep of the caller's +// home, so on a checkout the identity probe says nothing about, a key carrying +// the caller's own home path was named with it. It routes through the one +// canonical primitive now: the home is swept to `~` and the key still named. +func TestRefusedKeySweepsTheCallerHome(t *testing.T) { + home := filepath.Join(t.TempDir(), "zzcallerhome") + t.Setenv("HOME", home) + got := redactRefused(t.TempDir(), `json: unknown field "reviewer_notes `+home+`/notes"`) + if strings.Contains(got, "zzcallerhome") { + t.Errorf("the refusal names the caller's home: %q", got) + } + if !strings.Contains(got, "reviewer_notes ~/notes") { + t.Errorf("the refusal lost the key or did not sweep the home to ~: %q", got) + } +} diff --git a/internal/core/reading/ingest_schema_test.go b/internal/core/reading/ingest_schema_test.go index e8d849f72..062520a26 100644 --- a/internal/core/reading/ingest_schema_test.go +++ b/internal/core/reading/ingest_schema_test.go @@ -64,7 +64,9 @@ func TestMalformedPayloadWritesNothing(t *testing.T) { d := f.payload(1) d["position"] = "speculative" return d - }, "speculative"}, + // The refusal names the FIELD, never the refused value + // (iss-2609290043245353). + }, "position"}, // An EMPTY item list is not malformed. It was in this table, and the // refusal read the framework's clean-run contingency backwards: a run // that returned nothing is committed as a run with an empty item set, at diff --git a/internal/core/reading/project.go b/internal/core/reading/project.go index dd027bbba..766053d89 100644 --- a/internal/core/reading/project.go +++ b/internal/core/reading/project.go @@ -149,6 +149,12 @@ func redactExcluded(rel, doc string, exclusions []Exclusion) (string, error) { return out, nil } +// htmlTagOpen is what opens an HTML tag, the one definition htmlTagRe and +// htmlTagOpenRe are both built from: a `<`, an optional slash, and a name. The +// name is bounded so an AUTOLINK is left alone: `` looks like a tag +// until the colon. +const htmlTagOpen = ``) - // The tag name is bounded so an AUTOLINK is left alone: `` looks - // like a tag until the colon, and stripping it turns a heading carrying a URL - // into a different heading. - htmlTagRe = regexp.MustCompile(`]*)?/?>`) + // Its name is bounded (htmlTagOpen) so an AUTOLINK is left alone: stripping + // `` turns a heading carrying a URL into a different heading. + htmlTagRe = regexp.MustCompile(htmlTagOpen + `(?:\s[^>]*)?/?>`) + // htmlTagOpenRe is htmlTagRe's opening half, anchored: the `<` or ``. + htmlTagOpenRe = regexp.MustCompile(`^` + htmlTagOpen + `[\s/>]`) // mdLinkRe unwraps `[text](target)` to the text a reader sees. mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) // explicitYAMLKeyRe matches YAML's explicit-key form, `? origin`. @@ -1113,18 +1124,12 @@ func skipSpaceAndNewlines(s string, i int) int { return i } -// opensTag reports whether s[i] begins an HTML tag, on htmlTagRe's own rule: a -// `<` followed by a name, or by a slash and a name. An autolink and a bare `<` -// in prose open nothing, so neither drags the attribute walk over them. +// opensTag reports whether s[i] begins an HTML tag, on htmlTagRe's own rule, +// read through htmlTagOpenRe: a `<` followed by a name, or by a slash and a +// name, and the name ended by a space, a slash or a `>`. An autolink and a bare +// `<` in prose open nothing, so neither drags the attribute walk over them. func opensTag(s string, i int) bool { - if s[i] != '<' { - return false - } - j := i + 1 - if j < len(s) && s[j] == '/' { - j++ - } - return j < len(s) && (s[j] >= 'A' && s[j] <= 'Z' || s[j] >= 'a' && s[j] <= 'z') + return s[i] == '<' && htmlTagOpenRe.MatchString(s[i:]) } // maskAngles blanks the angle brackets in s[from:to], leaving every other byte — diff --git a/internal/core/reading/project_test.go b/internal/core/reading/project_test.go index 4f7084dae..24e3ee556 100644 --- a/internal/core/reading/project_test.go +++ b/internal/core/reading/project_test.go @@ -608,3 +608,22 @@ func TestTheEscapedKeyRefusalStatesOnlyWhatItKnows(t *testing.T) { t.Errorf("the refusal asserts a block shape the document does not have: %v", err) } } + +// TestOpensTagIsHTMLTagResRule is iss-2608301251394412: the attribute walk's +// opensTag and the title stripper's htmlTagRe are one definition of what opens +// a tag, so on any input the pattern can read to its `>`, the walk opens exactly +// where the pattern matches. The hand-written copy took a `<` and a letter for +// a tag, so it opened on an autolink its own comment said opens nothing. +func TestOpensTagIsHTMLTagResRule(t *testing.T) { + for _, s := range []string{ + "

", "

", "

", "
", "
", "", "", + "", "", "", + "< h2>", "<2>", "<-x>", "<>", "", "a < b >", + } { + loc := htmlTagRe.FindStringIndex(s) + want := loc != nil && loc[0] == 0 + if got := opensTag(s, 0); got != want { + t.Errorf("opensTag(%q) = %v, htmlTagRe matches at 0 = %v", s, got, want) + } + } +} diff --git a/internal/core/reading/redact.go b/internal/core/reading/redact.go index 1d26f042e..768b1099e 100644 --- a/internal/core/reading/redact.go +++ b/internal/core/reading/redact.go @@ -87,3 +87,18 @@ func noteDegraded(res *IngestResult, note string) { } res.Degraded += " " + note } + +// redactRefused renders payload text for a refusal that is RETURNED before the +// run's identity is proven, so before Ingest builds its payloadField: the +// decoder's message, which names an undeclared field by the payload's own key. +// That name is what the reader needs to find the fault, so it is kept and +// redacted rather than described (iss-2609290043245353). +// +// It is scanner.RedactRefusal, the one canonical refusal redactor (canonical +// patterns, then the literal sweep of the caller's home), followed by echo's +// cap. It FAILS CLOSED where newPayloadField degrades loudly: a returned refusal +// has no record to note a degradation in, so a scanner that cannot be built, or +// runs degraded, leaves the text described, never echoed. +func redactRefused(repoRoot, s string) string { + return echo(scanner.RedactRefusal(repoRoot, s)) +} diff --git a/internal/core/release/ingest.go b/internal/core/release/ingest.go index 9c4804990..a48f879e2 100644 --- a/internal/core/release/ingest.go +++ b/internal/core/release/ingest.go @@ -157,8 +157,21 @@ var ( // promptVersionRe validates the composing agent's prompt_version (itd-5), so a // release record can be traced to the prompt that worded it. promptVersionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + // payloadTagRe is the shape of a release tag a stale-cut refusal may quote: + // `v` and a bare semver, which can carry nothing to redact. + payloadTagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) ) +// tagOrDescribed renders the payload's next_tag for a refusal: quoted when it +// has a release tag's shape, and described otherwise, so a token or a path +// pasted there never reaches the terminal (iss-2609290218032954). +func tagOrDescribed(tag string) string { + if payloadTagRe.MatchString(tag) { + return fmt.Sprintf("%q", tag) + } + return termsafe.DescribeRefused(tag) +} + // ChangelogEntry is one composed changelog line — the untrusted input shape. type ChangelogEntry struct { // Section is the Keep-a-Changelog section this line belongs in. @@ -331,14 +344,14 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops return res, nil } - payload, perr := decodeChangelogPayload(raw) + payload, perr := decodeChangelogPayload(root, raw) if perr != nil { return res, perr } if payload.NextTag != cut.NextTag { - return res, refusal(ReasonStaleCut, "next_tag", "the payload was composed against %q but this cut derives %q — "+ + return res, refusal(ReasonStaleCut, "next_tag", "the payload was composed against %s but this cut derives %q — "+ "the record set moved under the composer; re-run the emit step and compose again", - termsafe.Sanitize(payload.NextTag), cut.NextTag) + tagOrDescribed(payload.NextTag), cut.NextTag) } // Every fault after decoding is collected in one pass, so the composer sees @@ -387,10 +400,16 @@ func ingest(root string, current surface.Snapshot, raw []byte, at time.Time, ops if err := checkOutbound(root, strings.Join(section, "\n"), "changelog section", "entries", &rs); err != nil { return res, err } + if err := checkPrivacy(root, strings.Join(section, "\n"), "changelog section", "entries", &rs); err != nil { + return res, err + } if hasPage { if err := checkOutbound(root, pageText, "release page", "press_release", &rs); err != nil { return res, err } + if err := checkPrivacy(root, pageText, "release page", "press_release", &rs); err != nil { + return res, err + } if err := checkPersonas(root, pageText, &rs); err != nil { return res, err } @@ -450,8 +469,15 @@ func checkPersonas(root, page string, rs *reasons) error { if err != nil { return err } + registry := cfg.Rules["persona_registry"].Registry for _, f := range findings { - rs.add(ReasonPersonaRegistry, "press_release", "line %d of the rendered page: %s", f.Line, termsafe.Sanitize(f.Message)) + // The finding's message names the persona the page attributes words to, + // which is payload prose and may be a person's name. No redactor knows a + // name, so it is described, as the headline refusal describes the same + // value; the line number locates it (iss-2609290218032954). + rs.add(ReasonPersonaRegistry, "press_release", "line %d of the rendered page attributes words to a persona that is "+ + "not in the registry (%s), a name not quoted; personas are selected by role and use the role's registered name", + f.Line, registry) } return nil } @@ -473,12 +499,42 @@ func checkOutbound(root, text, label, at string, rs *reasons) error { return nil } +// checkPrivacy holds one rendered document to the bar the launch scan holds the +// same file to: any hard_fail finding of the canonical scanner (a token, a key, +// the caller's own home or identity) adds a reason. The documents are public +// release text, so a finding is refused here, where the composer can drop it, +// rather than written and found by the launch scan after a person may have +// committed it (iss-2609290405381338). The detail names the kind and the line, +// never the matched text. A scanner that cannot be built, or runs degraded, is a +// stop: a weakened pattern set that reports nothing is not a check. +func checkPrivacy(root, text, label, at string, rs *reasons) error { + sc, err := scanner.New(root) + if err != nil { + return fmt.Errorf("the %s's privacy check: %w", label, err) + } + if unavail, reason := sc.Unavailable(); unavail { + return fmt.Errorf("the %s's privacy check: refusing to judge with a degraded scanner config: %s", label, reason) + } + for _, f := range sc.ScanText(text, label) { + if f.Severity != scanner.SeverityHardFail { + continue + } + rs.add(ReasonPrivacy, at, "the rendered %s carries a %s on line %d; remove it (release text is public, "+ + "and a secret or the composer's own identity has no place in it)", label, f.Kind, f.Line) + } + return nil +} + // decodeChangelogPayload reads the untrusted document behind the synthesis // guards: a size cap, an unknown-field refusal (an invented key means the // composer and this core disagree about the contract), the schema gate, and the // prompt_version stamp. Every fault here is structural — the document is // unusable, so nothing is written. -func decodeChangelogPayload(raw []byte) (ChangelogPayload, *PayloadRefusal) { +// +// The decoder's message names an undeclared field by the payload's own key, the +// one value the composer needs to find the fault, so it is redacted through the +// canonical scanner for root rather than quoted raw (iss-2609290218032954). +func decodeChangelogPayload(root string, raw []byte) (ChangelogPayload, *PayloadRefusal) { if len(raw) > MaxPayloadBytes { return ChangelogPayload{}, refusal(ReasonPayloadOversize, "", "changelog payload exceeds the %d-byte cap", MaxPayloadBytes) } @@ -486,7 +542,7 @@ func decodeChangelogPayload(raw []byte) (ChangelogPayload, *PayloadRefusal) { dec.DisallowUnknownFields() var p ChangelogPayload if err := dec.Decode(&p); err != nil { - msg := termsafe.Sanitize(err.Error()) + msg := scanner.RedactRefusal(root, err.Error()) if strings.HasPrefix(err.Error(), "json: unknown field") { return ChangelogPayload{}, refusal(ReasonUnknownField, "", "the payload carries a key this contract does not have: %s", msg) } @@ -540,8 +596,8 @@ func validateEntries(entries []ChangelogEntry, rs *reasons) ([]ChangelogEntry, m at := fmt.Sprintf("entries[%d]", i) switch { case !registeredSection[in.Section]: - rs.add(ReasonSection, at+".section", "entry %d names section %q; a changelog section must be one of %s", - n, termsafe.Sanitize(string(in.Section)), sectionList()) + rs.add(ReasonSection, at+".section", "entry %d names a section that is %s; a changelog section must be one of %s", + n, termsafe.DescribeRefused(string(in.Section)), sectionList()) case !writableSection[in.Section]: // Registered, so the shape is right; refused because the claim is one // the composer cannot check. Named apart from the structural refusal so @@ -568,8 +624,8 @@ func validateEntries(entries []ChangelogEntry, rs *reasons) ([]ChangelogEntry, m continue } if !payloadRecordIDRe.MatchString(id) { - rs.add(ReasonMalformedID, idAt, "entry %d cites %q, which is not a record id (want itd-N or iss-N)", - n, termsafe.Sanitize(id)) + rs.add(ReasonMalformedID, idAt, "entry %d cites %s, which is not a record id (want itd-N or iss-N)", + n, termsafe.DescribeRefused(id)) continue } if seen[id] { diff --git a/internal/core/release/ingest_test.go b/internal/core/release/ingest_test.go index 0f8c9e17c..7781b7b57 100644 --- a/internal/core/release/ingest_test.go +++ b/internal/core/release/ingest_test.go @@ -394,7 +394,7 @@ func TestIngestPayloadGuards(t *testing.T) { name: "an unregistered Keep-a-Changelog section", raw: `{"schema_version":2,"prompt_version":"1.0.0","next_tag":"v0.4.1",` + `"entries":[{"section":"Miscellaneous","records":["itd-73"],"text":"x"}]}`, - wantSaid: "Miscellaneous", + wantSaid: "section", }, { name: "a malformed record id", diff --git a/internal/core/release/page.go b/internal/core/release/page.go index 002ea2f2d..d38cd4c4e 100644 --- a/internal/core/release/page.go +++ b/internal/core/release/page.go @@ -197,8 +197,10 @@ func validatePage(cut Cut, p *PressReleasePayload, rs *reasons) validatedPage { rs.add(ReasonEmptyProse, at+".text", "the headline citing %s has no prose", strings.Join(ids, ", ")) } if name, found := lint.PersonaAttribution(h.Text); ok && found { - rs.add(ReasonBlockquote, at+".text", "the headline attributes words to %q (`said ,` or `says ,`), which only a verified quote may do; "+ - "carry the quote in quotes, or drop the attribution", termsafe.Sanitize(name)) + // The name is payload prose and may be a person's: described, never + // quoted (iss-2609290218032954). + rs.add(ReasonBlockquote, at+".text", "the headline attributes words to a name, %s (`said ,` or `says ,`), which only a verified quote may do; "+ + "carry the quote in quotes, or drop the attribution", termsafe.DescribeRefused(name)) } out.headlines = append(out.headlines, Headline{Records: ids, Text: text}) } @@ -266,7 +268,7 @@ func checkID(id, at string, rs *reasons) bool { return false } if !payloadRecordIDRe.MatchString(id) { - rs.add(ReasonMalformedID, at, "%q is not a record id (want itd-N or iss-N)", termsafe.Sanitize(id)) + rs.add(ReasonMalformedID, at, "%s is not a record id (want itd-N or iss-N)", termsafe.DescribeRefused(id)) return false } return true diff --git a/internal/core/release/page_test.go b/internal/core/release/page_test.go index 938bbb9fd..83fab93d2 100644 --- a/internal/core/release/page_test.go +++ b/internal/core/release/page_test.go @@ -480,9 +480,9 @@ func TestPageRefusesAnUnregisteredPersona(t *testing.T) { headline string quote Quote want ReasonCode // "" when the page passes - speaker string + speaker string // what the reason must carry: the line, never the name }{ - {"a quote from an unregistered persona", "", Quote{Record: "itd-73", Text: niaQuote, Attribution: "Nia"}, ReasonPersonaRegistry, "Nia"}, + {"a quote from an unregistered persona", "", Quote{Record: "itd-73", Text: niaQuote, Attribution: "Nia"}, ReasonPersonaRegistry, "line 5"}, {"a quote from a registered persona", "", goodPage().Quotes[0], "", ""}, {"a registered persona in headline prose", `"It works," said Iris, who cut the release.`, goodPage().Quotes[0], ReasonBlockquote, ""}, } { diff --git a/internal/core/release/payloadrefusal.go b/internal/core/release/payloadrefusal.go index 341cdf64e..9efd88c9f 100644 --- a/internal/core/release/payloadrefusal.go +++ b/internal/core/release/payloadrefusal.go @@ -74,6 +74,10 @@ const ( // repository's registry does not hold (record-lint's persona_registry rule, // run at the cut because the root page sits outside record-lint's roots). ReasonPersonaRegistry ReasonCode = "persona-registry" + // ReasonPrivacy: the rendered page or changelog section carries a hard_fail + // finding of the canonical scanner (a token, a key, the caller's own home or + // identity), the bar the launch scan holds the same files to. + ReasonPrivacy ReasonCode = "privacy" ) // ReasonCodes is every code, in the order above. commands/launch.md is pinned to @@ -87,7 +91,7 @@ var ReasonCodes = []ReasonCode{ ReasonChangelogMissing, ReasonChangelogInvented, ReasonChangelogInternal, ReasonMissing, ReasonOutsideSet, ReasonDuplicateCitation, ReasonNoHeadline, ReasonPageForEmptySet, ReasonHeading, ReasonFence, ReasonBlockquote, ReasonQuoteSource, ReasonQuoteNotVerbatim, - ReasonOutboundPolicy, ReasonPersonaRegistry, + ReasonOutboundPolicy, ReasonPersonaRegistry, ReasonPrivacy, } // Reason is one fault: what kind, where in the payload, and what exactly. diff --git a/internal/core/release/privacy_test.go b/internal/core/release/privacy_test.go new file mode 100644 index 000000000..2360ba5a8 --- /dev/null +++ b/internal/core/release/privacy_test.go @@ -0,0 +1,65 @@ +package release + +import ( + "path/filepath" + "strings" + "testing" +) + +// TestCutRefusesAHardFailFindingInReleaseText — iss-2609290405381338. The +// changelog lines and the page's headlines are host-composed prose written to +// CHANGELOG.md and RELEASE.md, public release text. They were held to the +// outbound policy alone, so a well-formed token or the caller's own home path +// was written as it stood. The rendered documents are now held to the bar the +// launch scan applies to the same files: any hard_fail finding refuses the cut, +// nothing is written, and the reason names the kind and the line, never the +// matched text. +func TestCutRefusesAHardFailFindingInReleaseText(t *testing.T) { + token := "ghp_" + strings.Repeat("A", 40) // FAKE GitHub PAT shape + home := filepath.Join(t.TempDir(), "zzcallerhome") + t.Setenv("HOME", home) + cases := []struct { + name, at, leak string + raw func(t *testing.T) []byte + }{ + {"a token in a changelog line", "entries", token, func(t *testing.T) []byte { + e := pageEntries() + e[2].Text = "Fixed the fetch that sent " + token + " upstream." + return marshalPage(t, "v0.4.1", e, goodPage()) + }}, + {"a token in a headline", "press_release", token, func(t *testing.T) []byte { + p := goodPage() + p.Headlines[0].Text = "Releases carry " + token + " now." + return marshalPage(t, "v0.4.1", pageEntries(), p) + }}, + {"the caller's home in a changelog line", "entries", "zzcallerhome", func(t *testing.T) []byte { + e := pageEntries() + e[2].Text = "Fixed the cache under " + home + "/cache." + return marshalPage(t, "v0.4.1", e, goodPage()) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := pageRepo(t) + before := treeDigest(t, r.Root()) + res, err := Ingest(r.Root(), liveSurface(), tc.raw(t), cutAt) + refusal := refusalOf(t, err) + reason, ok := reasonWith(refusal, ReasonPrivacy) + if !ok { + t.Fatalf("codes = %v, want %s", codesOf(refusal), ReasonPrivacy) + } + if reason.At != tc.at || !strings.Contains(reason.Detail, "line ") { + t.Errorf("reason = %+v, want it at %s naming the line", reason, tc.at) + } + if strings.Contains(refusal.Error(), tc.leak) { + t.Errorf("the refusal echoes the finding: %v", refusal) + } + if res.Written { + t.Error("a refused payload reported a write") + } + if after := treeDigest(t, r.Root()); after != before { + t.Error("a refused payload changed the working tree") + } + }) + } +} diff --git a/internal/core/release/refusal_echo_test.go b/internal/core/release/refusal_echo_test.go new file mode 100644 index 000000000..18cede1c0 --- /dev/null +++ b/internal/core/release/refusal_echo_test.go @@ -0,0 +1,118 @@ +package release + +import ( + "strings" + "testing" +) + +// releaseLeak carries a marker and a home path; releaseShortLeak is the same +// under the record-id length cap, so the refusal that matches the id, rather +// than the one that counts its bytes, is the one reached. +const ( + releaseLeak = "zzleak-7f3a /Users/zzotherperson/notes" // abcd-lint:allow — a planted home path the refusal must not echo + releaseShortLeak = "zzleak-7f3a/Users/zzotherperson" // abcd-lint:allow — a planted home path the refusal must not echo +) + +// TestChangelogRefusalsDoNotEchoThePayload — iss-2609290218032954. The +// changelog payload is host-composed, and its refusals quoted a stale next_tag, +// an out-of-set section, a malformed record id and a headline's attributed name +// through termsafe.Sanitize alone, which strips control sequences and redacts +// nothing; the decoder's undeclared-field message was returned the same way. A +// value is described now, and a key the reader needs is named redacted. +func TestChangelogRefusalsDoNotEchoThePayload(t *testing.T) { + cases := []struct { + name string + raw func(t *testing.T) []byte + leaks []string + names string + }{ + {"next_tag", func(t *testing.T) []byte { + return marshalPage(t, releaseLeak, pageEntries(), goodPage()) + }, []string{"zzleak-7f3a", "zzotherperson"}, "next_tag"}, + {"section", func(t *testing.T) []byte { + e := pageEntries() + e[0].Section = Section(releaseLeak) + return marshalPage(t, "v0.4.1", e, goodPage()) + }, []string{"zzleak-7f3a", "zzotherperson"}, "section"}, + {"changelog record id", func(t *testing.T) []byte { + e := pageEntries() + e[0].Records = append(e[0].Records, releaseShortLeak) + return marshalPage(t, "v0.4.1", e, goodPage()) + }, []string{"zzleak-7f3a", "zzotherperson"}, "record id"}, + {"page record id", func(t *testing.T) []byte { + p := goodPage() + p.Listed = append(p.Listed, releaseShortLeak) + return marshalPage(t, "v0.4.1", pageEntries(), p) + }, []string{"zzleak-7f3a", "zzotherperson"}, "record id"}, + {"headline attribution", func(t *testing.T) []byte { + p := goodPage() + p.Headlines[0].Text = `Nobody types a version any more, said Zzleakname, a product thinker.` + return marshalPage(t, "v0.4.1", pageEntries(), p) + }, []string{"Zzleakname"}, "attributes words"}, + {"undeclared key", func(t *testing.T) []byte { + raw := string(marshalPage(t, "v0.4.1", pageEntries(), goodPage())) + return []byte(strings.Replace(raw, `{`, `{"reviewer_notes /Users/zzotherperson/notes":1,`, 1)) // abcd-lint:allow — a planted home path in a KEY + }, []string{"zzotherperson"}, "reviewer_notes"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := pageRepo(t) + _, err := Ingest(r.Root(), liveSurface(), tc.raw(t), cutAt) + if err == nil { + t.Fatal("a payload carrying the leak was accepted") + } + for _, part := range tc.leaks { + if strings.Contains(err.Error(), part) { + t.Errorf("the refusal echoes the payload (%q): %v", part, err) + } + } + if !strings.Contains(err.Error(), tc.names) { + t.Errorf("the refusal no longer names %s: %v", tc.names, err) + } + }) + } +} + +// TestPersonaRefusalDoesNotEchoTheName — iss-2609290218032954. With the +// repository's persona_registry rule armed, the rendered page's finding named +// the attributed persona through scanner.RedactRefusal, whose patterns know +// tokens and paths but not a person's name, so the name was echoed verbatim +// while the headline refusal for the same value described it. The persona +// finding describes it too: the line number is what the reader needs. +func TestPersonaRefusalDoesNotEchoTheName(t *testing.T) { + for _, tc := range []struct { + name string + page func() *PressReleasePayload + leaks string + }{ + {"headline attribution", func() *PressReleasePayload { + p := goodPage() + p.Headlines[0].Text = `Nobody types a version any more, said Zzleakperson, a product thinker.` + return p + }, "Zzleakperson"}, + {"quote attribution", func() *PressReleasePayload { + p := goodPage() + p.Quotes = []Quote{{Record: "itd-73", Text: niaQuote, Attribution: "Nia"}} + return p + }, "Nia"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := pageRepo(t) + r.Write(".abcd/record-lint.json", `{"roots": [".abcd/development"], "rules": {"persona_registry": `+ + `{"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"}}}`+"\n") + r.Write(".abcd/development/personas.json", `{"personas": [{"name": "Iris"}]}`+"\n") + r.Commit("the persona registry") + _, err := Ingest(r.Root(), liveSurface(), marshalPage(t, "v0.4.1", pageEntries(), tc.page()), cutAt) + reason, ok := reasonWith(refusalOf(t, err), ReasonPersonaRegistry) + if !ok { + t.Fatalf("no %s reason: %v", ReasonPersonaRegistry, err) + } + if strings.Contains(reason.Detail, tc.leaks) { + t.Errorf("the persona finding echoes the attributed name %q: %s", tc.leaks, reason.Detail) + } + if !strings.Contains(reason.Detail, "line ") || !strings.Contains(reason.Detail, "not in the registry") { + t.Errorf("the persona finding no longer locates the fault: %s", reason.Detail) + } + }) + } +} diff --git a/internal/core/repolint/layout_evasions_test.go b/internal/core/repolint/layout_evasions_test.go new file mode 100644 index 000000000..f2a28095b --- /dev/null +++ b/internal/core/repolint/layout_evasions_test.go @@ -0,0 +1,92 @@ +package repolint_test + +import ( + "sort" + "strings" + "testing" +) + +// layoutFindings returns the files three-tier-layout names, sorted. +func layoutFindings(t *testing.T, b *repoBuilder) []string { + t.Helper() + var got []string + for _, f := range b.run().Findings { + if f.RuleID == "three-tier-layout" { + got = append(got, f.File) + } + } + sort.Strings(got) + return got +} + +// TestRule_LocalArtifactResidualEvasions — iss-173. The placement check matched +// the three names exactly, and only directly under a committed tier, so three +// shapes one step off the modelled incident passed clean: a handover file one +// directory below a tier root, a local-tier artefact at the .abcd/ root itself, +// and a name spelled in another case on a case-sensitive filesystem. NEXT.md is +// a name the local tier owns outright, so it is flagged at any depth in a +// committed tier; scratch/ and logs/ are ordinary words a durable record may +// legitimately nest (a study's logs/), so they are flagged where the local +// tier would put them — directly under a tier and at the .abcd/ root — and in +// any case. +func TestRule_LocalArtifactResidualEvasions(t *testing.T) { + cases := map[string]struct { + build func(b *repoBuilder) *repoBuilder + want []string + }{ + "nested handover": { + func(b *repoBuilder) *repoBuilder { return b.file(".abcd/work/notes/NEXT.md", "x\n") }, + []string{".abcd/work/notes/NEXT.md"}, + }, + "deeply nested handover in the durable record": { + func(b *repoBuilder) *repoBuilder { + return b.file(".abcd/development/research/study/NEXT.md", "x\n") + }, + []string{".abcd/development/research/study/NEXT.md"}, + }, + "handover at the .abcd root": { + func(b *repoBuilder) *repoBuilder { return b.file(".abcd/NEXT.md", "x\n") }, + []string{".abcd/NEXT.md"}, + }, + "scratch and logs at the .abcd root": { + func(b *repoBuilder) *repoBuilder { return b.dir(".abcd/scratch").dir(".abcd/logs") }, + []string{".abcd/logs", ".abcd/scratch"}, + }, + "lower-case handover": { + func(b *repoBuilder) *repoBuilder { return b.file(".abcd/work/next.md", "x\n") }, + []string{".abcd/work/next.md"}, + }, + "capitalised scratch": { + func(b *repoBuilder) *repoBuilder { return b.dir(".abcd/development/Scratch") }, + []string{".abcd/development/Scratch"}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := tc.build(newFixtureRepo(t).conforming()).commit() + got := layoutFindings(t, b) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("three-tier-layout names %v, want exactly %v", got, tc.want) + } + }) + } +} + +// TestRule_LocalArtifactWideningKeepsLegitimateTierContent: the widening must +// not start flagging what a committed tier legitimately holds — a study's own +// logs/ or scratch/ nested in the durable record, a file whose name merely +// begins like a handover, and the local tier's own contents. +func TestRule_LocalArtifactWideningKeepsLegitimateTierContent(t *testing.T) { + b := newFixtureRepo(t).conforming(). + dir(".abcd/development/research/study/logs"). + file(".abcd/development/research/study/logs/run-1.txt", "x\n"). + dir(".abcd/work/reviews/scratch"). + file(".abcd/work/NEXT-steps.md", "x\n"). + file(".abcd/work/reviews/next.md.bak", "x\n"). + dir(".abcd/.work.local/scratch"). + dir(".abcd/.work.local/logs"). + commit() + if got := layoutFindings(t, b); len(got) != 0 { + t.Errorf("legitimate tier content was flagged: %v", got) + } +} diff --git a/internal/core/repolint/rule_layout.go b/internal/core/repolint/rule_layout.go index eeef7e30f..fea526df5 100644 --- a/internal/core/repolint/rule_layout.go +++ b/internal/core/repolint/rule_layout.go @@ -1,7 +1,12 @@ package repolint import ( + "errors" + "io/fs" + "os" "path/filepath" + "strings" + "syscall" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -60,30 +65,27 @@ func (threeTierLayout) Eval(ctx Context) ([]Finding, error) { // Local-tier artefacts in a committed tier: NEXT.md, scratch/ and logs/ // are the local-ephemeral tier's conventional contents, so their presence - // directly under a committed tier is a placement error of the leak class — - // per-worktree ephemera about to enter (or already in) history. Presence - // is checked on the filesystem, like the tiers themselves: an untracked - // NEXT.md in .abcd/work/ is one `git add -A` from being committed. The - // check is no-follow: the NAME occupying the path is the violation - // regardless of what it is — a dangling symlink named NEXT.md still gets - // committed, and its target string can itself be a private path. - for _, artefact := range []string{"NEXT.md", "scratch", "logs"} { - rel := tier.rel + "/" + artefact - present, err := fsutil.ExistsNoFollow(filepath.Join(ctx.RepoRoot, filepath.FromSlash(rel))) - if err != nil { - return nil, err - } - if present { - out = append(out, Finding{ - RuleID: "three-tier-layout", - Severity: SeverityError, - File: rel, - Message: "local-tier artefact " + artefact + " found in the " + tier.label + " — per-worktree ephemera must never enter a committed tier", - Fix: "move " + rel + " to the local-ephemeral tier .abcd/.work.local/", - }) - } + // in a committed tier is a placement error of the leak class — per-worktree + // ephemera about to enter (or already in) history. Presence is checked on + // the filesystem, like the tiers themselves: an untracked NEXT.md in + // .abcd/work/ is one `git add -A` from being committed. The listing is + // no-follow: the NAME occupying the path is the violation regardless of + // what it is — a dangling symlink named NEXT.md still gets committed, and + // its target string can itself be a private path. + found, err := misplacedLocalArtefacts(ctx.RepoRoot, tier.rel, tier.label) + if err != nil { + return nil, err } + out = append(out, found...) + } + + // The .abcd/ root is one directory off the modelled incident and just as + // committed: a handover dropped there rides the same `git add -A`. + rootFound, err := misplacedAt(ctx.RepoRoot, ".abcd", ".abcd/ root", localArtefactNames) + if err != nil { + return nil, err } + out = append(out, rootFound...) // The local tier: only an issue when it is present but not gitignored, and // only when git can actually answer — git-absent is "cannot tell", never a @@ -106,3 +108,87 @@ func (threeTierLayout) Eval(ctx Context) ([]Finding, error) { return out, nil } + +// localArtefactNames are the local-ephemeral tier's conventional contents. They +// are matched in any case: on a case-sensitive filesystem `next.md` is a +// different file from NEXT.md and is committed just the same (iss-173). +var localArtefactNames = []string{"NEXT.md", "scratch", "logs"} + +// handoverName is the one artefact flagged at ANY depth in a committed tier. The +// handover file is a name the local tier owns outright, so a NEXT.md nested +// under a tier is the incident one directory down. scratch/ and logs/ are +// ordinary words a durable record may legitimately nest (a study's own logs/), +// so they are flagged only where the local tier would put them: directly under +// a tier root and at the .abcd/ root. +const handoverName = "NEXT.md" + +// misplacedLocalArtefacts reports the local-tier artefacts in the committed tier +// at tierRel: any of the three names directly under it, and a handover file at +// any depth below that. The walk never follows a symlink, so a linked directory +// is judged by its own name and never walked into. +func misplacedLocalArtefacts(repoRoot, tierRel, label string) ([]Finding, error) { + out, err := misplacedAt(repoRoot, tierRel, label, localArtefactNames) + if err != nil { + return nil, err + } + tierAbs := filepath.Join(repoRoot, filepath.FromSlash(tierRel)) + err = filepath.WalkDir(tierAbs, func(p string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + // Direct children were judged by misplacedAt; a handover below them is + // what the walk is for. + if filepath.Dir(p) == tierAbs || p == tierAbs { + return nil + } + if !strings.EqualFold(d.Name(), handoverName) { + return nil + } + rel, rerr := filepath.Rel(repoRoot, p) + if rerr != nil { + return rerr + } + out = append(out, artefactFinding(filepath.ToSlash(rel), d.Name(), label)) + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// misplacedAt reports each entry directly inside dirRel whose name is one of +// names in any case. An absent directory holds nothing to report. +func misplacedAt(repoRoot, dirRel, label string, names []string) ([]Finding, error) { + entries, err := os.ReadDir(filepath.Join(repoRoot, filepath.FromSlash(dirRel))) + if os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) { + // Absent, or not a directory at all: nothing sits inside it, and the + // missing tier is reported as itself. + return nil, nil + } + if err != nil { + return nil, err + } + var out []Finding + for _, e := range entries { + for _, n := range names { + if strings.EqualFold(e.Name(), n) { + out = append(out, artefactFinding(dirRel+"/"+e.Name(), e.Name(), label)) + break + } + } + } + return out, nil +} + +// artefactFinding is the one finding shape every misplacement produces, naming +// the path as it is spelled on disk. +func artefactFinding(rel, name, label string) Finding { + return Finding{ + RuleID: "three-tier-layout", + Severity: SeverityError, + File: rel, + Message: "local-tier artefact " + name + " found in the " + label + " — per-worktree ephemera must never enter a committed tier", + Fix: "move " + rel + " to the local-ephemeral tier .abcd/.work.local/", + } +} diff --git a/internal/core/rules/defaults/rules.json b/internal/core/rules/defaults/rules.json index c4a5c45a1..508709ae8 100644 --- a/internal/core/rules/defaults/rules.json +++ b/internal/core/rules/defaults/rules.json @@ -9,6 +9,8 @@ "rules": [ "Conventional prefix (feat/fix/chore/refactor/docs/test, no scopes); small atomic commits, one purpose each.", "AI-assisted commits carry an Assisted-by trailer (kernel format); never Co-Authored-By for AI.", + "Commit as the human: an agent or autonomous run sets its git identity to the person responsible, never the tool's name or a bot address.", + "Outward text (a pull-request body, an issue, a comment) carries no agent-session URL and no tool 'generated with' footer: after creating one, re-read what was posted and strip either shape the harness appended.", "Substantive work goes on a branch + PR; never force-push, never --no-verify.", "Never commit or push unless asked." ] diff --git a/internal/core/rules/home_fold_test.go b/internal/core/rules/home_fold_test.go new file mode 100644 index 000000000..9bfd8e264 --- /dev/null +++ b/internal/core/rules/home_fold_test.go @@ -0,0 +1,32 @@ +//go:build unix + +package rules + +import ( + "path/filepath" + "testing" +) + +// TestTrustedRootsCaseVariantFollowsTheFoldPredicate: a trusted-roots entry +// spelled in a case variant of the marker re-admits it exactly when the +// filesystem folds case. The branch cannot be provoked on a case-sensitive +// host, so the predicate is forced both ways (iss-2609090951297149). The marker +// does not exist, so neither spelling resolves to the other. +func TestTrustedRootsCaseVariantFollowsTheFoldPredicate(t *testing.T) { + parent := t.TempDir() + marker := filepath.Join(parent, "checkout-absent") + home := t.TempDir() + t.Setenv("HOME", home) + declareTrusted(t, home, filepath.Join(parent, "CHECKOUT-ABSENT")) + real := caseFoldingFS + t.Cleanup(func() { caseFoldingFS = real }) + + caseFoldingFS = func() bool { return true } + if ok, note := trustedRootDeclared(marker); !ok { + t.Errorf("fold on: a case-variant declaration of the marker must re-admit it; note %q", note) + } + caseFoldingFS = func() bool { return false } + if ok, note := trustedRootDeclared(marker); ok { + t.Errorf("fold off: a case-variant declaration names a different root and must re-admit nothing; note %q", note) + } +} diff --git a/internal/core/rules/root.go b/internal/core/rules/root.go index 23f5d25ec..bedea9261 100644 --- a/internal/core/rules/root.go +++ b/internal/core/rules/root.go @@ -33,6 +33,13 @@ const maxTrustedRootsBytes = 64 << 10 // with it left alone, that a root the caller really owns is admitted unchanged. var ownerUID = fsutil.OwnerUID +// caseFoldingFS is the package's view of fsutil.CaseFoldingFS, held as a var +// for the reason ownerUID is: the case-folding branch of the trusted-roots +// match cannot be provoked on a case-sensitive host, so substituting the +// predicate is the only way a detector can prove what a case-variant +// declaration does (iss-2609090951297149). +var caseFoldingFS = fsutil.CaseFoldingFS + // ResolveRoot resolves the repo root the per-repo configuration under .abcd/ is // read from — rules.json and config.json here, guard.json for the shell guard, // which shares this resolver so a session's rules and its guard can never come @@ -358,46 +365,17 @@ func trustedRootDeclared(marker string) (bool, string) { if err != nil || home == "" { return false, "" } - // The guard is fsutil.ReadHomeDeclaration's, not this function's: the - // three home-scoped declaration records differ in what they declare, never in - // what makes a declaration trustworthy, and the copy that skipped two of the - // checks was the one whose consequence is code execution - // (iss-2609091927085132). Only the WORDING stays here. - raw, refusal, err := fsutil.ReadHomeDeclaration(home, TrustedRootsRelPath, maxTrustedRootsBytes) - switch refusal { - case fsutil.DeclarationOK: - case fsutil.DeclarationAbsent: - return false, "" // no declaration is the ordinary case, not a diagnostic. - case fsutil.DeclarationBehindSymlink: - return false, ignoredDeclaration(termsafe.Sanitize(err.Error())) - case fsutil.DeclarationNotRegular: - return false, ignoredDeclaration("it is not a regular file") - case fsutil.DeclarationWritableByOthers: - return false, ignoredDeclaration("it is writable by others, so its contents are not necessarily yours") - case fsutil.DeclarationForeignOwner: - return false, ignoredDeclaration("it is not owned by this session's uid") - default: - return false, ignoredDeclaration("it could not be read (" + termsafe.Sanitize(err.Error()) + ")") + // The guard and the match are fsutil.HomeDeclarationNames', not this + // function's: the home-scoped declaration records differ in what they + // declare, never in what makes a declaration trustworthy or how an entry + // names a path, and the copy that skipped two of the checks was the one + // whose consequence is code execution (iss-2609091927085132, + // iss-2609090951283654). Only the WORDING stays here. + declared, why := fsutil.HomeDeclarationNames(home, TrustedRootsRelPath, maxTrustedRootsBytes, marker, caseFoldingFS()) + if why != "" { + return false, ignoredDeclaration(why) } - fold := fsutil.CaseFoldingFS() - want := fsutil.FoldPath(marker, fold) - for _, line := range strings.Split(string(raw), "\n") { - entry := strings.TrimSpace(line) - if entry == "" || strings.HasPrefix(entry, "#") { - continue - } - if !filepath.IsAbs(entry) { - continue // a relative entry names a different directory per caller. - } - // Both spellings: the entry as written, and symlink-resolved, because - // marker is already resolved and a declared path may not be. - for _, cand := range []string{filepath.Clean(entry), resolveOrClean(entry)} { - if fsutil.FoldPath(cand, fold) == want { - return true, "" - } - } - } - return false, "" + return declared, "" } // ignoredDeclaration renders the one-line reason a present declaration was not @@ -406,12 +384,3 @@ func trustedRootDeclared(marker string) (bool, string) { func ignoredDeclaration(why string) string { return "rules: IGNORED " + TrustedRootsDisplay + " — " + why + "; it re-admitted nothing" } - -// resolveOrClean is EvalSymlinks with a lexical fallback, so a declared path -// that does not exist (a bind mount not currently mounted) still compares. -func resolveOrClean(p string) string { - if real, err := filepath.EvalSymlinks(p); err == nil { - return real - } - return filepath.Clean(p) -} diff --git a/internal/core/rules/rules_test.go b/internal/core/rules/rules_test.go index 585df884b..d2f57f877 100644 --- a/internal/core/rules/rules_test.go +++ b/internal/core/rules/rules_test.go @@ -224,6 +224,42 @@ func TestPIIDomainForbidsCommittingNetworkIdentifiers(t *testing.T) { t.Fatalf("no PII rule forbids committing hostnames, IP addresses, MAC addresses, or live network identifiers: %v", pii.Rules) } +// TestCommittingDomainCarriesTheRecurringAttributionRules: the bundled +// COMMITTING domain is what every managed repo's agents are handed per prompt, +// so it carries the two attribution failures that keep recurring rather than +// leaving them to per-agent memory and routine prompts (iss-2608210923437502): +// outward text the harness decorated is read back and stripped after it is +// created, and a commit is made as the human, never as the tool. +func TestCommittingDomainCarriesTheRecurringAttributionRules(t *testing.T) { + committing, ok := Defaults().Lookup("COMMITTING") + if !ok { + t.Fatal("COMMITTING domain missing from the bundled defaults") + } + for name, words := range map[string][]string{ + "read back and strip what the harness appended": {"re-read", "strip", "session url", "footer"}, + "commit as the human, never as the tool": {"git identity", "human", "never the tool"}, + } { + found := false + for _, rule := range committing.Rules { + low := strings.ToLower(rule) + hit := true + for _, w := range words { + if !strings.Contains(low, w) { + hit = false + break + } + } + if hit { + found = true + break + } + } + if !found { + t.Errorf("no COMMITTING rule says to %s (wanting all of %q): %q", name, words, committing.Rules) + } + } +} + func TestMatchRecallKeyword(t *testing.T) { rs := Defaults() got := rs.Match("let's commit and push this") diff --git a/internal/core/scribe/ancestor_symlink_test.go b/internal/core/scribe/ancestor_symlink_test.go new file mode 100644 index 000000000..7e47acf70 --- /dev/null +++ b/internal/core/scribe/ancestor_symlink_test.go @@ -0,0 +1,37 @@ +package scribe + +import ( + "os" + "path/filepath" + "testing" +) + +// TestScribeAssembleRefusesASymlinkedLocalTier: the default run directory is +// relative, inside the checkout's local tier, and was created with os.MkdirAll, +// which follows a symlinked ancestor — a hostile clone that force-adds +// `.abcd/.work.local` as a link parks the context and the manifest wherever the +// link points. The reading assembler proves each level of its in-repo run +// directory (iss-2609262231500173); found on the sweep for iss-2609012037137250, +// the scribe's is proved the same way. +func TestScribeAssembleRefusesASymlinkedLocalTier(t *testing.T) { + f := newFixture(t, positionDetection, 1) + outside := t.TempDir() + local := filepath.Join(f.repo, ".abcd", ".work.local") + if err := os.RemoveAll(local); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, local); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + if _, err := Assemble(AssembleRequest{RepoRoot: f.repo, Run: fixtureRun, + DispositionsPath: supply(t, suppliedText)}); err == nil { + t.Fatal("Assemble parked the run through a symlinked .abcd/.work.local") + } + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("the refused assembly still created %d entr(y|ies) under the symlink's target", len(entries)) + } +} diff --git a/internal/core/scribe/assemble.go b/internal/core/scribe/assemble.go index d3b06c560..774231931 100644 --- a/internal/core/scribe/assemble.go +++ b/internal/core/scribe/assemble.go @@ -151,7 +151,19 @@ func Assemble(req AssembleRequest) (AssembleResult, error) { if label == "" { label = outDir } - if err := writePair(dir, label, contextRaw, manifestRaw); err != nil { + // A run directory named inside the repository by a relative path — the + // local-tier default above all — is created one level at a time, each level + // proved real (fsutil.EnsureRealDirAll), as the reading assembler does: a + // committed symlink at .abcd or .abcd/.work.local must not carry the + // session's artefacts out of the checkout. + rel := path.Clean(filepath.ToSlash(outDir)) + inRepo := !filepath.IsAbs(outDir) && fsutil.ValidRelPath(rel) + if inRepo && path.Dir(rel) != "." { + if err := fsutil.EnsureRealDirAll(req.RepoRoot, path.Dir(rel), 0o755); err != nil { + return AssembleResult{}, fmt.Errorf("scribe: creating the output directory: %w", err) + } + } + if err := writePair(dir, label, inRepo, contextRaw, manifestRaw); err != nil { return AssembleResult{}, err } res.Written = true @@ -356,7 +368,7 @@ func definitionHash(repoRoot string) (string, error) { // writePair writes the context and the manifest into an empty or absent // directory, both or neither, on the reading assembler's rules. -func writePair(dir, label string, contextRaw, manifestRaw []byte) error { +func writePair(dir, label string, inRepo bool, contextRaw, manifestRaw []byte) error { entries, err := os.ReadDir(dir) switch { case os.IsNotExist(err): @@ -367,7 +379,13 @@ func writePair(dir, label string, contextRaw, manifestRaw []byte) error { "session's artefacts are one session's evidence, so ingest or clear the session parked there, "+ "or name an empty directory", label, len(entries)) } - if err := os.MkdirAll(dir, 0o755); err != nil { + if inRepo { + // The leaf is proved too: ReadDir above follows a symlinked leaf, and + // an empty directory behind one would read as a fresh run directory. + if err := fsutil.EnsureRealDir(dir, 0o755); err != nil { + return fmt.Errorf("scribe: creating the output directory: %w", err) + } + } else if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("scribe: creating the output directory: %w", err) } ctxPath := filepath.Join(dir, ContextFileName) diff --git a/internal/core/scribe/assemble_test.go b/internal/core/scribe/assemble_test.go index 8f898163d..dd3f866d9 100644 --- a/internal/core/scribe/assemble_test.go +++ b/internal/core/scribe/assemble_test.go @@ -108,7 +108,7 @@ func TestScribeManifestNamesEveryPathPassed(t *testing.T) { f := newFixture(t, positionDetection, 2) res := assembleFixture(t, f, suppliedText) contextRaw := readParked(t, f, ContextFileName) - m, err := DecodeManifest(readParked(t, f, ManifestFileName)) + m, err := DecodeManifest(readParked(t, f, ManifestFileName), f.repo) if err != nil { t.Fatalf("the parked manifest does not decode: %v", err) } diff --git a/internal/core/scribe/ingest.go b/internal/core/scribe/ingest.go index e8d22979c..32bfd3b60 100644 --- a/internal/core/scribe/ingest.go +++ b/internal/core/scribe/ingest.go @@ -7,8 +7,10 @@ package scribe // This is a trust boundary. The payload is an agent's output, read behind the // guarded reader with a byte cap, decoded strictly with every key checked // against the closed shapes, and no payload string is joined into a path before -// its grammar is checked: the run id is matched against recordid first. Every -// payload string quoted into a message is neutralised and capped. +// its grammar is checked: the run id is matched against recordid first. A +// payload string in a refusal is quoted only when it has a closed shape, and is +// described otherwise; a key name the reader needs is redacted through the +// canonical scanner (iss-2609290218032954). // // The one validation this verb adds is the one only it can make: that the scribe // AUTHORED NOTHING. The scribe reformats; it never adds a word. So every item it @@ -28,11 +30,13 @@ import ( "sort" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/reading" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" ) // IngestRequest is one scribe ingest. @@ -169,15 +173,16 @@ func Ingest(req IngestRequest) (IngestResult, error) { if err != nil { return IngestResult{}, fmt.Errorf("scribe: reading the scribe output: %w", err) } - out, err := decodeOutput(raw) + out, err := decodeOutput(raw, req.RepoRoot) if err != nil { return IngestResult{}, err } if out.Type != OutputType { - return IngestResult{}, fmt.Errorf("scribe: the output's _type is %q, want %q", echo(out.Type), OutputType) + return IngestResult{}, fmt.Errorf("scribe: the output's _type is %s, want %q", typeTag(out.Type, OutputType), OutputType) } if !recordid.ValidReadingRunID(out.Run) { - return IngestResult{}, fmt.Errorf("scribe: the output names run %q, which is not a reading run id (rdg-N)", echo(out.Run)) + return IngestResult{}, fmt.Errorf("scribe: the output's run is %s, which is not a reading run id (rdg-N)", + termsafe.DescribeRefused(out.Run)) } // The run's identity, proven before anything is written: the context on @@ -239,7 +244,7 @@ func Ingest(req IngestRequest) (IngestResult, error) { for i, s := range out.Surprises { r, err := capture.Surprise(capture.SurpriseRequest{RepoRoot: req.RepoRoot, OccasionedBy: s.OccasionedBy, Text: s.Text}) if err != nil { - return res, stopped(res, fmt.Sprintf("surprises[%d] (%s)", i, s.OccasionedBy), err) + return res, stopped(res, entryLabel("surprises", i, s.OccasionedBy), err) } res.Surprises = append(res.Surprises, r) } @@ -282,12 +287,12 @@ func landedList(res IngestResult) string { // decodes strictly. The key walk comes first so a refusal names the field the // scribe authored and the entry it sat on, which the decoder's own message does // not. -func decodeOutput(raw []byte) (Output, error) { +func decodeOutput(raw []byte, repoRoot string) (Output, error) { var top map[string]json.RawMessage if err := json.Unmarshal(raw, &top); err != nil { return Output{}, fmt.Errorf("scribe: the output is not a JSON object: %w", err) } - if err := refuseKeys("the output", top, topKeys); err != nil { + if err := refuseKeys(repoRoot, "the output", top, topKeys); err != nil { return Output{}, err } for _, k := range requiredTopKeys { @@ -308,7 +313,6 @@ func decodeOutput(raw []byte) (Output, error) { return Output{}, fmt.Errorf("scribe: %q is not a list of objects: %w", list, err) } for i, e := range entries { - where := fmt.Sprintf("%s[%d]", list, i) var subject string for _, k := range []string{"item", "occasioned_by", "subject"} { if v, ok := e[k]; ok { @@ -316,23 +320,23 @@ func decodeOutput(raw []byte) (Output, error) { break } } - if subject != "" { - where += " (" + echo(subject) + ")" - } - if err := refuseKeys(where, e, allowed); err != nil { + if err := refuseKeys(repoRoot, entryLabel(list, i, subject), e, allowed); err != nil { return Output{}, err } } } var out Output - if err := decodeStrict(raw, &out, "the scribe output"); err != nil { + if err := decodeStrict(raw, &out, "the scribe output", repoRoot); err != nil { return Output{}, err } return out, nil } // refuseKeys refuses the first key, in sorted order, that is not in allowed. -func refuseKeys(where string, obj map[string]json.RawMessage, allowed map[string]bool) error { +// The key is the payload's own spelling and the one thing the reader needs to +// find the fault, so it is named redacted through the canonical scanner +// (iss-2609290218032954). +func refuseKeys(repoRoot, where string, obj map[string]json.RawMessage, allowed map[string]bool) error { keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) @@ -342,7 +346,7 @@ func refuseKeys(where string, obj map[string]json.RawMessage, allowed map[string if !allowed[k] { return fmt.Errorf("scribe: %s carries %q, a field the scribe may not author; the scribe transcribes "+ "the declared shapes and authors nothing, so the payload is refused and nothing is written", - where, echo(k)) + where, echo(scanner.RedactRefusal(repoRoot, k))) } } return nil @@ -364,7 +368,7 @@ func proveContext(req IngestRequest, out Output) (Context, Manifest, error) { return Context{}, Manifest{}, fmt.Errorf("scribe: reading the parked manifest for %s: %w; assemble the "+ "session first, and ingest against the context it parked", out.Run, err) } - m, err := DecodeManifest(mRaw) + m, err := DecodeManifest(mRaw, req.RepoRoot) if err != nil { return Context{}, Manifest{}, err } @@ -378,15 +382,15 @@ func proveContext(req IngestRequest, out Output) (Context, Manifest, error) { } if out.ContextSHA256 != m.ContextSHA256 { return Context{}, Manifest{}, fmt.Errorf("scribe: the output cites context %s and the parked context "+ - "is %s, so the output is not from this session; nothing is written", echo(out.ContextSHA256), m.ContextSHA256) + "is %s, so the output is not from this session; nothing is written", digestTag(out.ContextSHA256), m.ContextSHA256) } - ctx, err := decodeContext(cRaw) + ctx, err := decodeContext(cRaw, req.RepoRoot) if err != nil { return Context{}, Manifest{}, err } if ctx.Run != out.Run || m.Run != out.Run || ctx.ContextStamp != m.ContextStamp { return Context{}, Manifest{}, fmt.Errorf("scribe: the output names run %s, the context %s and the "+ - "manifest %s; one session is over one run", echo(out.Run), ctx.Run, m.Run) + "manifest %s; one session is over one run", out.Run, handle(ctx.Run), handle(m.Run)) } return ctx, m, nil } @@ -408,7 +412,7 @@ func proveSupplied(req IngestRequest, ctx Context, m Manifest) (string, error) { if got := sha256Hex([]byte(supplied)); got != m.Supplied.DispositionsSHA256 { return "", fmt.Errorf("scribe: the supplied dispositions hash to %s and the parked manifest records %s, "+ "so the session was not assembled over this text, or the parked pair was rewritten; nothing is "+ - "written", got, echo(m.Supplied.DispositionsSHA256)) + "written", got, digestTag(m.Supplied.DispositionsSHA256)) } if ctx.Supplied.Dispositions != supplied { return "", fmt.Errorf("scribe: the context's copy of the supplied dispositions is not the researcher's " + @@ -478,14 +482,14 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { if text == "" || strings.Contains(folded, fold(text)) { return nil } - return fmt.Errorf("scribe: %s carries a %s the researcher did not write (%q does not stand in the "+ - "supplied dispositions); the scribe reformats and never adds a word, so the payload is refused and "+ - "nothing is written", where, field, echo(text)) + return fmt.Errorf("scribe: %s carries a %s the researcher did not write (the %s, %s, does not stand in "+ + "the supplied dispositions); the scribe reformats and never adds a word, so the payload is refused and "+ + "nothing is written", where, field, field, termsafe.DescribeRefused(text)) } ofRun := func(where, id string) error { if _, ok := items[id]; !ok { return fmt.Errorf("scribe: %s names %s, which is not an item of %s; a scribe session transcribes "+ - "one run", where, echo(id), out.Run) + "one run", where, handle(id), out.Run) } return nil } @@ -494,32 +498,32 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { answer := func(where, id string) error { if prior, dup := answered[id]; dup { return fmt.Errorf("scribe: %s answers %s, which %s already answers; one item takes one answer in "+ - "one payload", where, echo(id), prior) + "one payload", where, handle(id), prior) } answered[id] = where return nil } for i, d := range out.Dispositions { - where := fmt.Sprintf("dispositions[%d] (%s)", i, echo(d.Item)) + where := entryLabel("dispositions", i, d.Item) if err := ofRun(where, d.Item); err != nil { return err } if !named(d.Item) { return fmt.Errorf("scribe: %s is a disposition the researcher did not supply: the supplied "+ - "dispositions never name %s", where, echo(d.Item)) + "dispositions never name %s", where, handle(d.Item)) } // The state is the ruling itself, so it is held to the supplied text as // the grounds are, and more tightly: it must stand whole-word on a line // that names the item, because a state another item's line carries is not // the researcher's answer to this one. if !lineCarries(supplied, d.Item, d.State) { - return fmt.Errorf("scribe: %s carries state %q, and no line of the supplied dispositions that names "+ + return fmt.Errorf("scribe: %s carries state %s, and no line of the supplied dispositions that names "+ "%s carries it; the state is the researcher's ruling and the scribe never supplies one, so the "+ - "payload is refused and nothing is written", where, echo(d.State), echo(d.Item)) + "payload is refused and nothing is written", where, stateTag(d.State), handle(d.Item)) } for _, id := range append([]string{d.Supersedes}, d.Recurs...) { if id != "" && !named(id) { - return fmt.Errorf("scribe: %s cites %s, which the supplied dispositions never name", where, echo(id)) + return fmt.Errorf("scribe: %s cites %s, which the supplied dispositions never name", where, handle(id)) } } if err := verbatim(where, "grounds", d.Grounds); err != nil { @@ -533,13 +537,13 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { } } for i, a := range out.Admissions { - where := fmt.Sprintf("admissions[%d] (%s)", i, echo(a.Item)) + where := entryLabel("admissions", i, a.Item) if err := ofRun(where, a.Item); err != nil { return err } if !named(a.Item) { return fmt.Errorf("scribe: %s is an admission the researcher did not supply: the supplied "+ - "dispositions never name %s", where, echo(a.Item)) + "dispositions never name %s", where, handle(a.Item)) } // An admission writes an accepted disposition, so it is a state too, held // by the same rule: the item's own line admits or accepts the proposal. @@ -547,7 +551,7 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { return fmt.Errorf("scribe: %s is an admission, and no line of the supplied dispositions that names "+ "%s admits or accepts it (%s); an admission writes an acceptance, which is the researcher's "+ "ruling and never the scribe's, so the payload is refused and nothing is written", - where, echo(a.Item), strings.Join(admissionTokens, ", ")) + where, handle(a.Item), strings.Join(admissionTokens, ", ")) } if err := verbatim(where, "grounds", a.Grounds); err != nil { return err @@ -555,17 +559,17 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { // An admission and a disposition of one item are one act when their // grounds agree, which Admit holds; a second admission is not. if prior, dup := answered[a.Item]; dup && strings.HasPrefix(prior, "admissions") { - return fmt.Errorf("scribe: %s admits %s, which %s already admits", where, echo(a.Item), prior) + return fmt.Errorf("scribe: %s admits %s, which %s already admits", where, handle(a.Item), prior) } if _, dup := answered[a.Item]; !dup { answered[a.Item] = where } } for i, s := range out.Surprises { - where := fmt.Sprintf("surprises[%d] (%s)", i, echo(s.OccasionedBy)) + where := entryLabel("surprises", i, s.OccasionedBy) if !named(s.OccasionedBy) { return fmt.Errorf("scribe: %s is keyed to %s, which the supplied dispositions never name", - where, echo(s.OccasionedBy)) + where, handle(s.OccasionedBy)) } if strings.TrimSpace(s.Text) == "" { return fmt.Errorf("scribe: %s carries no text", where) @@ -581,7 +585,7 @@ func refuseAuthored(out Output, supplied string, items map[string]bool) error { } if prior, dup := answered[id]; dup { return fmt.Errorf("scribe: %s lists %s as outstanding and %s answers it; an outstanding item is "+ - "one given no disposition", where, echo(id), prior) + "one given no disposition", where, handle(id), prior) } answered[id] = where } diff --git a/internal/core/scribe/ingest_refusal_echo_test.go b/internal/core/scribe/ingest_refusal_echo_test.go new file mode 100644 index 000000000..91fa5921b --- /dev/null +++ b/internal/core/scribe/ingest_refusal_echo_test.go @@ -0,0 +1,154 @@ +package scribe + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +// echoLeak is a payload value carrying a marker and a home path. A refusal that +// echoes a payload-chosen value carries both to the terminal and the transcript. +const echoLeak = "zzleak-7f3a /Users/zzotherperson/notes long enough to quote" // abcd-lint:allow — a planted home path the refusal must not echo + +// assertNoEcho fails when err is nil, carries either half of the leak, or no +// longer names where the fault is. +func assertNoEcho(t *testing.T, err error, names string) { + t.Helper() + if err == nil { + t.Fatal("a payload carrying the leak was accepted") + } + for _, part := range []string{"zzleak-7f3a", "zzotherperson"} { + if strings.Contains(err.Error(), part) { + t.Errorf("the refusal echoes the payload: %v", err) + } + } + if !strings.Contains(err.Error(), names) { + t.Errorf("the refusal no longer names %s: %v", names, err) + } +} + +// TestScribeRefusalsDoNotEchoThePayload — iss-2609290218032954. Every scribe +// ingest refusal returns with nothing written, and each of these carried the +// payload's own value back through echo(), which cleans and caps but does not +// redact. A value of a closed shape (a run or item id, a state, a digest) is +// quoted only when it has that shape, and described otherwise; free text the +// researcher did not write is described; an undeclared key, the one value the +// reader needs, is redacted through the canonical scanner and still named. +func TestScribeRefusalsDoNotEchoThePayload(t *testing.T) { + const supplied = "{0}: accepted — " + groundA + ".\n{1}: I have not decided yet.\n" + cases := []struct { + name string + mutate func(s session, o *Output) + names string + }{ + {"_type", func(_ session, o *Output) { o.Type = echoLeak }, "_type"}, + {"run", func(_ session, o *Output) { o.Run = echoLeak }, "run"}, + {"context_sha256", func(_ session, o *Output) { o.ContextSHA256 = echoLeak }, "context"}, + {"disposition item", func(_ session, o *Output) { o.Dispositions[0].Item = echoLeak }, "dispositions[0]"}, + {"disposition state", func(_ session, o *Output) { o.Dispositions[0].State = echoLeak }, "state"}, + {"disposition grounds", func(_ session, o *Output) { o.Dispositions[0].Grounds = echoLeak }, "grounds"}, + {"disposition exit_condition", func(_ session, o *Output) { o.Dispositions[0].ExitCondition = echoLeak }, "exit_condition"}, + {"disposition supersedes", func(_ session, o *Output) { o.Dispositions[0].Supersedes = echoLeak }, "cites"}, + {"disposition recurs", func(_ session, o *Output) { o.Dispositions[0].Recurs = []string{echoLeak} }, "cites"}, + {"admission item", func(_ session, o *Output) { + o.Admissions = []OutAdmission{{Item: echoLeak, Grounds: groundA}} + }, "admissions[0]"}, + {"surprise occasioned_by", func(_ session, o *Output) { + o.Surprises = []OutSurprise{{OccasionedBy: echoLeak, Text: groundA}} + }, "surprises[0]"}, + {"surprise text", func(s session, o *Output) { + o.Surprises = []OutSurprise{{OccasionedBy: s.items[0], Text: echoLeak}} + }, "text"}, + {"outstanding", func(_ session, o *Output) { o.Outstanding = []string{echoLeak} }, "outstanding[0]"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := assembleSession(t, positionDetection, 2, supplied) + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + o.Outstanding = []string{s.items[1]} + tc.mutate(s, &o) + _, err := s.ingest(t, s.write(t, o)) + assertNoEcho(t, err, tc.names) + }) + } +} + +// TestScribeKeyRefusalsRedactTheKey: an undeclared key, the subject an entry is +// labelled by, and a repeated key are payload text in a refusal. The key is +// named with the planted home path redacted, and a subject that is not an item +// handle is described. +func TestScribeKeyRefusalsRedactTheKey(t *testing.T) { + const supplied = "{0}: accepted — " + groundA + ".\n" + cases := []struct { + name string + raw func(s session) string + names string + }{ + {"undeclared top-level key", func(s session) string { + return `{"_type":"` + OutputType + `","run":"` + fixtureRun + `","context_sha256":"` + s.res.ContextSHA256 + + `","reviewer_notes /Users/zzotherperson/notes":"x"}` // abcd-lint:allow — a planted home path in a KEY + }, "reviewer_notes"}, + {"entry labelled by a leaking subject", func(s session) string { + item, _ := json.Marshal(echoLeak) + return `{"_type":"` + OutputType + `","run":"` + fixtureRun + `","context_sha256":"` + s.res.ContextSHA256 + + `","dispositions":[{"item":` + string(item) + `,"verdict":"x"}]}` + }, "verdict"}, + {"repeated nested key", func(s session) string { + return `{"_type":"` + OutputType + `","run":"` + fixtureRun + `","context_sha256":"` + s.res.ContextSHA256 + + `","dispositions":[{"item":"` + s.items[0] + `","grounds":{"k /Users/zzotherperson/a":1,"k /Users/zzotherperson/a":2}}]}` // abcd-lint:allow — a planted home path in a KEY + }, "duplicate key"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := assembleSession(t, positionDetection, 1, supplied) + _, err := s.ingest(t, s.writeRaw(t, tc.raw(s))) + assertNoEcho(t, err, tc.names) + }) + } +} + +// TestScribeParkedPairRefusalsDoNotEcho: the parked manifest sits where a +// scribe session with tools can rewrite it, so its _type, an undeclared key in +// it and its supplied hash are payload-chosen too. +func TestScribeParkedPairRefusalsDoNotEcho(t *testing.T) { + const supplied = "{0}: accepted — " + groundA + ".\n" + cases := []struct { + name string + mutate func(m map[string]any) + names string + }{ + {"manifest _type", func(m map[string]any) { m["_type"] = echoLeak }, "manifest"}, + {"manifest undeclared key", func(m map[string]any) { m["notes /Users/zzotherperson/notes"] = "x" }, "notes"}, // abcd-lint:allow — a planted home path in a KEY + {"manifest supplied hash", func(m map[string]any) { + m["supplied"] = map[string]any{"dispositions_sha256": echoLeak} + }, "supplied dispositions"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := assembleSession(t, positionDetection, 1, supplied) + parked := filepath.Join(s.repo, filepath.FromSlash(DefaultRunDir), fixtureRun, ManifestFileName) + raw, err := os.ReadFile(parked) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + tc.mutate(m) + raw, _ = json.Marshal(m) + if err := os.WriteFile(parked, raw, 0o644); err != nil { + t.Fatal(err) + } + o := s.out() + o.Dispositions = []OutDisposition{{Item: s.items[0], State: issueschema.DispositionAccepted, Grounds: groundA}} + _, err = s.ingest(t, s.write(t, o)) + assertNoEcho(t, err, tc.names) + }) + } +} diff --git a/internal/core/scribe/scribe.go b/internal/core/scribe/scribe.go index 7999699ee..889f00fb4 100644 --- a/internal/core/scribe/scribe.go +++ b/internal/core/scribe/scribe.go @@ -37,9 +37,11 @@ import ( "fmt" "strings" + "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/core/capture" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/jsonstrict" + "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/termsafe" ) @@ -185,17 +187,24 @@ func encode(v any) ([]byte, error) { // decodeStrict decodes one document, refusing a repeated key at any depth, // unknown fields and trailing content. The scribe output, the manifest and the // context all decode through it. -func decodeStrict(data []byte, into any, what string) error { +// +// Both refusals name a key by the document's own spelling, a repeated key and +// an undeclared one, and all three documents are payload-chosen (the parked +// pair sits where a scribe session with tools can rewrite it). The key is what +// the reader needs to find the fault, so the message is redacted through the +// canonical scanner rather than described (iss-2609290218032954). +func decodeStrict(data []byte, into any, what, repoRoot string) error { // A repeated key at any depth is refused, not read last-wins: encoding/json // would take {"state":"declined","state":"accepted"} as accepted. jsonstrict // is the one check every trust-boundary reader shares (iss-2609261036363114). if err := jsonstrict.NoDuplicateKeys(data); err != nil { - return fmt.Errorf("scribe: decoding %s: %w; nothing is written", what, err) + return fmt.Errorf("scribe: decoding %s: %s; nothing is written", what, + scanner.RedactRefusal(repoRoot, err.Error())) } dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(into); err != nil { - return fmt.Errorf("scribe: decoding %s: %w", what, err) + return fmt.Errorf("scribe: decoding %s: %s", what, scanner.RedactRefusal(repoRoot, err.Error())) } if dec.More() { return fmt.Errorf("scribe: decoding %s: trailing content after the document", what) @@ -203,28 +212,29 @@ func decodeStrict(data []byte, into any, what string) error { return nil } -// DecodeManifest reads a scribe manifest strictly. -func DecodeManifest(data []byte) (Manifest, error) { +// DecodeManifest reads a scribe manifest strictly. repoRoot is the repository +// whose scanner redacts a refusal's key names. +func DecodeManifest(data []byte, repoRoot string) (Manifest, error) { var m Manifest - if err := decodeStrict(data, &m, "the scribe manifest"); err != nil { + if err := decodeStrict(data, &m, "the scribe manifest", repoRoot); err != nil { return Manifest{}, err } if m.Type != ManifestType || m.SchemaVersion != SchemaVersion { - return Manifest{}, fmt.Errorf("scribe: the manifest is %q version %d, want %q version %d", - echo(m.Type), m.SchemaVersion, ManifestType, SchemaVersion) + return Manifest{}, fmt.Errorf("scribe: the manifest's _type is %s at version %d, want %q version %d", + typeTag(m.Type, ManifestType), m.SchemaVersion, ManifestType, SchemaVersion) } return m, nil } // decodeContext reads a scribe context strictly. -func decodeContext(data []byte) (Context, error) { +func decodeContext(data []byte, repoRoot string) (Context, error) { var c Context - if err := decodeStrict(data, &c, "the scribe context"); err != nil { + if err := decodeStrict(data, &c, "the scribe context", repoRoot); err != nil { return Context{}, err } if c.Type != ContextType || c.SchemaVersion != SchemaVersion { - return Context{}, fmt.Errorf("scribe: the context is %q version %d, want %q version %d", - echo(c.Type), c.SchemaVersion, ContextType, SchemaVersion) + return Context{}, fmt.Errorf("scribe: the context's _type is %s at version %d, want %q version %d", + typeTag(c.Type, ContextType), c.SchemaVersion, ContextType, SchemaVersion) } return c, nil } @@ -255,6 +265,60 @@ func echo(s string) string { // a rune. func utf8Start(b byte) bool { return b&0xC0 != 0x80 } +// The renderings below are for a payload-chosen value in a REFUSAL, which +// returns to the terminal and the transcript with nothing written. echo cleans +// and caps but does not redact, so a value is quoted only when it has a closed +// shape that can carry nothing to redact, and is otherwise described by +// termsafe.DescribeRefused: the refusal's field name and position already say +// where the fault is (iss-2609290218032954). + +// handle renders a record handle the payload names: a run id (rdg-N) or an +// item, admission or disposition handle (rdi-N, adm-N, dsp-N). +func handle(id string) string { + if recordid.ValidReadingRunID(id) || issueschema.ValidSurpriseOccasion(id) { + return id + } + return termsafe.DescribeRefused(id) +} + +// entryLabel names one entry of a payload list, with its handle when the +// handle has the closed shape, and by its position alone otherwise. +func entryLabel(list string, i int, id string) string { + if h := handle(id); h == id { + return fmt.Sprintf("%s[%d] (%s)", list, i, id) + } + return fmt.Sprintf("%s[%d]", list, i) +} + +// stateTag renders a disposition state: one of the shipped vocabulary is +// quoted, anything else described. +func stateTag(state string) string { + for _, s := range issueschema.DispositionStates { + if state == s { + return fmt.Sprintf("%q", state) + } + } + return termsafe.DescribeRefused(state) +} + +// digestTag renders a sha-256 digest: 64 lower-case hex digits are quoted, +// anything else described. +func digestTag(d string) string { + if len(d) == 64 && strings.Trim(d, "0123456789abcdef") == "" { + return d + } + return termsafe.DescribeRefused(d) +} + +// typeTag renders a document's _type, which is refused only when it is not the +// one tag wanted, so it is always described. +func typeTag(got, want string) string { + if got == want { + return fmt.Sprintf("%q", got) + } + return termsafe.DescribeRefused(got) +} + // joinEchoed renders a list of payload-derived names for a message. func joinEchoed(in []string) string { out := make([]string, 0, len(in)) diff --git a/internal/core/site/build_test.go b/internal/core/site/build_test.go index ed25a9832..f3db6c1a8 100644 --- a/internal/core/site/build_test.go +++ b/internal/core/site/build_test.go @@ -1112,6 +1112,49 @@ func TestBuildLandingCarriesProvenance(t *testing.T) { } } +// TestBuildAtAV1ReleaseDropsTheBetaBadge is the other half of itd-135 ac-6: +// the badge is a rule on the release version, so the same sources built at a +// v1 release carry no badge and no "beta" in the footer, with no copy change +// (iss-2609261423210391). +func TestBuildAtAV1ReleaseDropsTheBetaBadge(t *testing.T) { + f := newFixture(t) + f.write("CHANGELOG.md", strings.Join([]string{ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [1.0.0] - 2026-02-11", + "", + "### Added", + "", + "- The shipped one. (itd-2)", + "", + }, "\n")) + out := t.TempDir() + res, err := Build(Request{RepoRoot: f.Root(), OutDir: out, + Stamp: BuildStamp{Version: "1.0.0", Commit: "abcdef1", GeneratedAt: "2026-02-11"}}) + if err != nil { + t.Fatalf("build: %v", err) + } + if res.Version != "1.0.0" { + t.Fatalf("version %q, want the v1 release this test builds at", res.Version) + } + data, err := os.ReadFile(filepath.Join(out, "index.html")) + if err != nil { + t.Fatal(err) + } + html := string(data) + if strings.Contains(html, `class="beta"`) { + t.Error("the Beta badge rendered at a v1 release") + } + if strings.Contains(html, "beta") { + t.Error("the footer still says beta at a v1 release") + } + if !strings.Contains(html, "v1.0.0") { + t.Error("the footer does not name the v1 release, so this is not the build the test claims") + } +} + // TestBuildWithoutChangelog is the graceful-absence rule (itd-140): a missing // optional source omits what depends on it and the build still succeeds. func TestBuildWithoutChangelog(t *testing.T) { diff --git a/internal/core/spec/ancestor_symlink_test.go b/internal/core/spec/ancestor_symlink_test.go new file mode 100644 index 000000000..c3dc4f625 --- /dev/null +++ b/internal/core/spec/ancestor_symlink_test.go @@ -0,0 +1,79 @@ +package spec + +import ( + "os" + "path/filepath" + "testing" +) + +// The spec store's directory creator refused a symlinked LEAF and then called +// os.MkdirAll, which follows a symlinked ANCESTOR and creates the rest of the +// chain under its target — so a committed `.abcd/development -> ` +// redirected the mint and every later write out of the checkout +// (iss-2609012037137250, the sibling of GHSA-865x-5m7q-qm79). The intent store +// closed the same hole through fsutil.EnsureRealDirAll; these pin the spec side. + +// plantAncestorLink makes root/.abcd/development a symlink to a fresh +// directory outside root and returns that directory. +func plantAncestorLink(t *testing.T, root string) string { + t.Helper() + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, ".abcd", "development")); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + return outside +} + +// assertEmptyDir fails when dir holds anything: a refusal that still created a +// directory under the link's target has already written outside the checkout. +func assertEmptyDir(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Fatalf("the refused write still created %v under the symlink's target %s", names, dir) + } +} + +func TestCreateRefusesASymlinkedAncestor(t *testing.T) { + root := t.TempDir() + outside := plantAncestorLink(t, root) + if _, err := Create(root, "itd-9", "my-feature", ""); err == nil { + t.Fatal("Create minted a spec through a symlinked .abcd/development") + } + assertEmptyDir(t, outside) +} + +func TestCloseRefusesASymlinkedAncestor(t *testing.T) { + // The store is minted for real in a directory that is later reached only + // through the link, so Close meets an existing open/ spec behind a + // symlinked ancestor and must not create closed/ under the target. + staging := t.TempDir() + sp, err := Create(staging, "itd-9", "my-feature", "") + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(staging, ".abcd", "development") + if err := os.Symlink(target, filepath.Join(root, ".abcd", "development")); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + if _, err := Close(root, sp.ID); err == nil { + t.Fatal("Close moved a spec through a symlinked .abcd/development") + } + if _, err := os.Lstat(filepath.Join(target, "specs", StatusClosed)); !os.IsNotExist(err) { + t.Fatalf("the refused close still created specs/closed under the symlink's target (lstat err = %v)", err) + } +} diff --git a/internal/core/spec/store.go b/internal/core/spec/store.go index 78180b069..008722839 100644 --- a/internal/core/spec/store.go +++ b/internal/core/spec/store.go @@ -210,7 +210,7 @@ func create(repoRoot, intentID string, intents []string, bundle, slug, productio return err } openDir := filepath.Join(repoRoot, SpecsRelDir, StatusOpen) - if err := ensureDir(openDir, filepath.Join(SpecsRelDir, StatusOpen)); err != nil { + if err := ensureDir(repoRoot, filepath.Join(SpecsRelDir, StatusOpen)); err != nil { return err } name := fmt.Sprintf("%s-%s.md", id, slug) @@ -302,11 +302,15 @@ func withStoreLock(repoRoot string, timeout time.Duration, mode storeLockMode, f } specsDir := filepath.Join(repoRoot, SpecsRelDir) if mode == createStore { - if err := ensureDir(specsDir, SpecsRelDir); err != nil { + if err := ensureDir(repoRoot, SpecsRelDir); err != nil { return err } - } else if di, err := os.Lstat(specsDir); err == nil && di.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("spec: %s is a symlink (refusing to follow)", SpecsRelDir) + } else if _, err := fsutil.ProbeRealDirAll(repoRoot, filepath.ToSlash(SpecsRelDir)); err != nil { + // The read-only twin of the create walk: an existing store reached + // through a symlinked ancestor is refused, not locked and written + // through. An absent level is no error here; the lock's own open below + // reports it as errStoreAbsent. + return fmt.Errorf("spec: %s: %w", SpecsRelDir, err) } fd, err := syscall.Open(specsDir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) if mode == storeMustExist && errors.Is(err, syscall.ENOENT) { @@ -424,7 +428,7 @@ func closeLocked(repoRoot, specID string) (Spec, error) { } name := filepath.Base(sp.Path) closedDir := filepath.Join(repoRoot, SpecsRelDir, StatusClosed) - if err := ensureDir(closedDir, filepath.Join(SpecsRelDir, StatusClosed)); err != nil { + if err := ensureDir(repoRoot, filepath.Join(SpecsRelDir, StatusClosed)); err != nil { return Spec{}, err } dstRel := filepath.Join(SpecsRelDir, StatusClosed, name) @@ -504,15 +508,13 @@ func readRepoFile(abs, rel string) ([]byte, error) { return data, nil } -// ensureDir creates dir if absent, refusing a symlinked leaf directory. -// NOTE: a symlinked ANCESTOR (e.g. a symlinked specs/) is not caught here — a -// low-severity follow-up under the trusted-worktree model (planting one needs -// write access equal to editing the record directly). -func ensureDir(dir, rel string) error { - if di, err := os.Lstat(dir); err == nil && di.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("spec: %s is a symlink (refusing to follow)", rel) - } - if err := os.MkdirAll(dir, 0o755); err != nil { +// ensureDir creates repoRoot/rel one level at a time, proving every level — +// ancestors included — a real directory (fsutil.EnsureRealDirAll). A committed +// symlink at .abcd, .abcd/development or specs/ is refused rather than followed, +// so a mint or a close cannot write outside the checkout (iss-2609012037137250, +// the create site the intent store closed in iss-2609091128479544). +func ensureDir(repoRoot, rel string) error { + if err := fsutil.EnsureRealDirAll(repoRoot, filepath.ToSlash(rel), 0o755); err != nil { return fmt.Errorf("spec: creating %s: %w", rel, err) } return nil diff --git a/internal/core/surface/appendix.go b/internal/core/surface/appendix.go index d7181d3ab..9e2fbee92 100644 --- a/internal/core/surface/appendix.go +++ b/internal/core/surface/appendix.go @@ -68,14 +68,24 @@ var ( ) // UnbuiltSentence is the whole appendix of a chapter whose surface the command -// tree does not register: a staged design target, or a host-delegated command -// with no Go verb. Every chapter carries a block, so a reader learns the surface +// tree does not register and whose register row does not read shipped: a staged +// design target. Every chapter carries a block, so a reader learns the surface // is unbuilt from the place they would have read its flags, never from absence. func UnbuiltSentence(path string) string { return "There is no shipped surface: the command tree registers no `" + path + "` verb, so there are no flags and no sub-verbs to list." } +// HostDelegatedSentence is the whole appendix of a chapter whose register row +// reads shipped while the command tree registers no verb for it: a +// host-delegated command, which ships as a command page the host carries out +// (iss-2609231931006041). Saying there is no shipped surface there would +// contradict the register row it pairs with. +func HostDelegatedSentence(path string) string { + return "It ships as a host-delegated command page: the command tree registers no `" + path + + "` verb, so there are no flags and no sub-verbs to list." +} + // ComposeAppendix renders the generated region for a chapter documenting the // given command paths, in the order given (the register's order). Each shipped // command is listed with every descendant, depth-first by path, each with its @@ -88,6 +98,24 @@ func UnbuiltSentence(path string) string { // exit code or an output field cannot reach the block until the tree records it // somewhere this function is handed. func ComposeAppendix(paths []string, tree []Command) string { + return composeAppendix(paths, nil, tree) +} + +// Appendix renders the chapter's generated region against tree. It is +// ComposeAppendix with the register's word on each command: a command the tree +// does not register is host-delegated when its row reads shipped, and unbuilt +// otherwise. +func (ch Chapter) Appendix(tree []Command) string { + return composeAppendix(ch.Commands, ch.Shipped, tree) +} + +func composeAppendix(paths []string, shippedRow map[string]bool, tree []Command) string { + absent := func(p string) string { + if shippedRow[p] { + return HostDelegatedSentence(p) + } + return UnbuiltSentence(p) + } byPath := make(map[string]Command, len(tree)) for _, c := range tree { byPath[c.Path] = c @@ -102,7 +130,7 @@ func ComposeAppendix(paths []string, tree []Command) string { b.WriteString("\n") if !shipped { for _, p := range paths { - b.WriteString(UnbuiltSentence(p) + "\n\n") + b.WriteString(absent(p) + "\n\n") } return b.String() } @@ -111,7 +139,7 @@ func ComposeAppendix(paths []string, tree []Command) string { for _, p := range paths { c, ok := byPath[p] if !ok { - fmt.Fprintf(&b, "### `%s`\n\n%s\n\n", p, UnbuiltSentence(p)) + fmt.Fprintf(&b, "### `%s`\n\n%s\n\n", p, absent(p)) continue } if movedWhole(c, tree) { @@ -600,6 +628,8 @@ func cells(line string) []string { type Chapter struct { File string Commands []string + // Shipped holds the commands whose register row reads shipped. + Shipped map[string]bool } // Chapters pairs the chapter files with the register's rows. Every chapter file @@ -615,6 +645,7 @@ func Chapters(rows []RegisterRow, files []string) ([]Chapter, error) { } var refusals []error byFile := map[string][]string{} + shipped := map[string]bool{} for _, r := range rows { if r.Chapter == "" { continue @@ -624,6 +655,9 @@ func Chapters(rows []RegisterRow, files []string) ([]Chapter, error) { continue } byFile[r.Chapter] = append(byFile[r.Chapter], r.Command) + if r.Status == "shipped" { + shipped[r.Command] = true + } } sorted := append([]string(nil), files...) sort.Strings(sorted) @@ -634,7 +668,13 @@ func Chapters(rows []RegisterRow, files []string) ([]Chapter, error) { refusals = append(refusals, fmt.Errorf("%s: %w; add its row to %s/%s", f, ErrChapterWithoutRow, BriefSurfacesDir, RegisterFile)) continue } - out = append(out, Chapter{File: f, Commands: cmds}) + ch := Chapter{File: f, Commands: cmds, Shipped: map[string]bool{}} + for _, c := range cmds { + if shipped[c] { + ch.Shipped[c] = true + } + } + out = append(out, ch) } return out, errors.Join(refusals...) } @@ -680,7 +720,7 @@ func RegenerateChapters(dir string, tree []Command) ([]RegeneratedChapter, error if err != nil { return nil, err } - want, err := RenderChapter(string(text), ComposeAppendix(ch.Commands, tree)) + want, err := RenderChapter(string(text), ch.Appendix(tree)) if err != nil { refusals = append(refusals, fmt.Errorf("%s: %w", ch.File, err)) continue diff --git a/internal/core/surface/appendix_test.go b/internal/core/surface/appendix_test.go index c200efb58..c40eaab83 100644 --- a/internal/core/surface/appendix_test.go +++ b/internal/core/surface/appendix_test.go @@ -395,3 +395,32 @@ func TestRegenerateChaptersSkipsAndReportsARefusedChapter(t *testing.T) { t.Errorf("regenerated %v; want every chapter but the refused ones", names) } } + +// iss-2609231931006041: a host-delegated command ships as a command page with no +// Go verb, so its register row reads shipped while the tree registers nothing. +// Its appendix must not claim there is no shipped surface; only a staged row +// earns that sentence. +func TestChapterAppendixTellsHostDelegatedFromUnbuilt(t *testing.T) { + register := "| # | Command | Status | Purpose | File |\n" + + "|---|---|---|---|---|\n" + + "| 1 | `/abcd:consult` | shipped | Ask the corpus | [`13-consult.md`](13-consult.md) |\n" + + "| 2 | `/abcd:reflect` | staged | Retrospective | [`09-reflect.md`](09-reflect.md) |\n" + chapters, err := Chapters(ParseRegister(register), []string{"09-reflect.md", "13-consult.md"}) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, ch := range chapters { + got[ch.File] = ch.Appendix(fixtureTree()) + } + if want := "\n" + UnbuiltSentence("abcd reflect") + "\n\n"; got["09-reflect.md"] != want { + t.Errorf("staged chapter appendix = %q, want %q", got["09-reflect.md"], want) + } + consult := got["13-consult.md"] + if strings.Contains(consult, "no shipped surface") { + t.Errorf("a shipped host-delegated chapter claims there is no shipped surface: %q", consult) + } + if want := "\n" + HostDelegatedSentence("abcd consult") + "\n\n"; consult != want { + t.Errorf("host-delegated chapter appendix = %q, want %q", consult, want) + } +} diff --git a/internal/fsutil/home.go b/internal/fsutil/home.go index 9caed88f5..6af62dd64 100644 --- a/internal/fsutil/home.go +++ b/internal/fsutil/home.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "syscall" + + "github.com/intentdriven/abcd/internal/termsafe" ) // ErrHomeScopeSymlinked is the refusal for a home-scoped path whose DIRECTORY @@ -347,3 +349,67 @@ func readDeclarationIn(root *os.Root, leaf, p string, limit int64, deny os.FileM } return data, DeclarationOK, nil } + +// HomeDeclarationNames reads the line-oriented path declaration at rel under +// home through ReadHomeDeclaration and reports whether one of its entries names +// target. It is the one reader behind every "declare this checkout" opt-in — +// ~/.abcd/trusted-roots for the rules resolver, ~/.abcd/local-transcript-roots +// for the transcript store — so a hardening of what a declaration must be, or +// of how an entry is matched, lands once and reaches every caller +// (iss-2609090951283654). +// +// An entry is a trimmed line that is neither blank nor a "#" comment and is +// absolute: a relative entry names a different directory per caller and names +// nothing here. Each entry is compared in two spellings, as written and +// symlink-resolved, against target in the same two spellings, because a +// declared path need not be resolved and a caller's target may be either. +// The comparison key is FoldPath under fold; fold is a parameter, not a call to +// CaseFoldingFS, so each caller holds the predicate in a variable a test can +// force and the case-folding branch is provable on a case-sensitive host +// (iss-2609090951297149). +// +// ignored is empty when there is no declaration (the ordinary case, never a +// diagnostic) or when it was read; otherwise it is the terminal-safe clause +// saying why a present declaration was not honoured, for the caller to render +// in its own voice. A declaration that was not honoured names nothing. +func HomeDeclarationNames(home, rel string, limit int64, target string, fold bool) (declared bool, ignored string) { + raw, refusal, err := ReadHomeDeclaration(home, rel, limit) + switch refusal { + case DeclarationOK: + case DeclarationAbsent: + return false, "" + case DeclarationBehindSymlink: + return false, termsafe.Sanitize(err.Error()) + case DeclarationNotRegular: + return false, "it is not a regular file" + case DeclarationWritableByOthers: + return false, "it is writable by others, so its contents are not necessarily yours" + case DeclarationForeignOwner: + return false, "it is not owned by this session's uid" + default: + return false, "it could not be read (" + termsafe.Sanitize(err.Error()) + ")" + } + want := []string{FoldPath(filepath.Clean(target), fold), FoldPath(resolveOrClean(target), fold)} + for _, line := range strings.Split(string(raw), "\n") { + entry := strings.TrimSpace(line) + if entry == "" || strings.HasPrefix(entry, "#") || !filepath.IsAbs(entry) { + continue + } + for _, cand := range []string{filepath.Clean(entry), resolveOrClean(entry)} { + key := FoldPath(cand, fold) + if key == want[0] || key == want[1] { + return true, "" + } + } + } + return false, "" +} + +// resolveOrClean is EvalSymlinks with a lexical fallback, so a declared path +// that does not currently exist (a bind mount not mounted) still compares. +func resolveOrClean(p string) string { + if real, err := filepath.EvalSymlinks(p); err == nil { + return real + } + return filepath.Clean(p) +} diff --git a/internal/fsutil/home_declared_test.go b/internal/fsutil/home_declared_test.go new file mode 100644 index 000000000..f22ce613e --- /dev/null +++ b/internal/fsutil/home_declared_test.go @@ -0,0 +1,98 @@ +//go:build unix + +package fsutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// declareIn writes a home-scoped path declaration under home/.abcd/name with +// mode, and returns the rel HomeDeclarationNames reads it by. +func declareIn(t *testing.T, home, name, body string, mode os.FileMode) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + p := filepath.Join(home, ".abcd", name) + if err := os.WriteFile(p, []byte(body), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatal(err) + } + return ".abcd/" + name +} + +// The fold branch of the declaration match is decided by the fold argument, +// never by the host: with it set a case-variant spelling of the target is the +// same path, and with it clear it is a different one. Both halves run on every +// host because the paths do not exist, so neither spelling can resolve to the +// other (iss-2609090951297149). +func TestHomeDeclarationNamesFoldsCaseOnlyWhenTold(t *testing.T) { + home := t.TempDir() + rel := declareIn(t, home, "roots", "/Example/Checkout-Absent\n", 0o600) + target := "/example/checkout-absent" + if declared, ignored := HomeDeclarationNames(home, rel, 1<<10, target, true); !declared || ignored != "" { + t.Errorf("fold on: a case-variant declaration of the target must be honoured; declared %v, ignored %q", declared, ignored) + } + if declared, ignored := HomeDeclarationNames(home, rel, 1<<10, target, false); declared || ignored != "" { + t.Errorf("fold off: a case-variant declaration names a different path; declared %v, ignored %q", declared, ignored) + } +} + +// An entry and the target are each compared as written and symlink-resolved, +// so a declaration spelled through a link names the resolved target and a +// resolved declaration names a target spelled through a link. The two callers +// once differed here: one resolved only the entry (iss-2609090951283654). +func TestHomeDeclarationNamesComparesBothSpellingsOfEachSide(t *testing.T) { + realDir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "via-link") + if err := os.Symlink(realDir, link); err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ name, entry, target string }{ + {"entry through a link, target resolved", link, realDir}, + {"entry resolved, target through a link", realDir, link}, + } { + home := t.TempDir() + rel := declareIn(t, home, "roots", tc.entry+"\n", 0o600) + if declared, ignored := HomeDeclarationNames(home, rel, 1<<10, tc.target, false); !declared { + t.Errorf("%s: not honoured (ignored %q)", tc.name, ignored) + } + } +} + +// Only an absolute, uncommented line is an entry: a relative line names a +// different directory per caller, and a comment names nothing. +func TestHomeDeclarationNamesIgnoresRelativeAndCommentedLines(t *testing.T) { + home := t.TempDir() + rel := declareIn(t, home, "roots", "# /example/checkout-absent\nexample/checkout-absent\n\n", 0o600) + if declared, _ := HomeDeclarationNames(home, rel, 1<<10, "/example/checkout-absent", false); declared { + t.Fatal("a commented or relative line re-admitted the target") + } +} + +// A declaration that is present and not honoured says why, in a clause the +// caller renders in its own voice; an absent one is the ordinary case and +// says nothing. A present, unvouched-for declaration names nothing even when +// its text names the target. +func TestHomeDeclarationNamesSaysWhyAPresentDeclarationWasIgnored(t *testing.T) { + home := t.TempDir() + if declared, ignored := HomeDeclarationNames(home, ".abcd/roots", 1<<10, "/example/checkout-absent", false); declared || ignored != "" { + t.Fatalf("an absent declaration: declared %v, ignored %q; want false and no diagnostic", declared, ignored) + } + rel := declareIn(t, home, "roots", "/example/checkout-absent\n", 0o622) + declared, ignored := HomeDeclarationNames(home, rel, 1<<10, "/example/checkout-absent", false) + if declared { + t.Fatal("a declaration writable by others re-admitted the target") + } + if !strings.Contains(ignored, "writable by others") { + t.Fatalf("the ignored declaration must say why: %q", ignored) + } +} diff --git a/internal/fsutil/paths.go b/internal/fsutil/paths.go index e0625094a..acb121db6 100644 --- a/internal/fsutil/paths.go +++ b/internal/fsutil/paths.go @@ -204,8 +204,10 @@ func RepoRel(base, target string) string { // RedactRoot replaces every occurrence of the absolute directory root in s with // repl — both a path UNDER the root (root + separator + …) and the BARE root -// itself when it sits at a right boundary (end of string or a non-path -// character). The bare-root case matters because a message that names exactly +// itself when it sits at a right boundary: the end of s, or any byte that is not +// a letter or a digit (NameContinues), so ".", "-old" and +// "_snapshot" are redacted while "/rootfs" under "/root" is not. The +// bare-root case matters because a message that names exactly // $HOME (e.g. "cannot access /Users/alex") would otherwise leak the developer abcd-audit:allow // identity — its base segment IS the username. The filesystem root ("/") and // empty or relative roots are skipped so a message is never mangled. @@ -239,7 +241,7 @@ func RedactRoot(s, root, repl string) string { // replaceRoot replaces each occurrence of root that starts a path (a left // boundary) and is either the whole path or its directory prefix (a right -// boundary: end of string, a separator, or a non-path character). A span that +// boundary: no letter or digit follows it, NameContinues). A span that // is not redacted is re-emitted in its original casing, and the scan resumes // one byte on so an occurrence overlapping a rejected one is still judged. func replaceRoot(s, root, repl string, fold bool) string { @@ -252,8 +254,8 @@ func replaceRoot(s, root, repl string, fold bool) string { } i := from + j after := i + len(root) - left := i == 0 || s[i-1] == os.PathSeparator || isPathBoundary(s[i-1]) - right := after == len(s) || s[after] == os.PathSeparator || isPathBoundary(s[after]) + left := i == 0 || s[i-1] == os.PathSeparator || !IsPathSegmentByte(s[i-1]) + right := !NameContinues(s, after) if !left || !right { from = i + 1 continue @@ -393,16 +395,27 @@ func relInside(root, p string) (string, bool) { return rel, true } -// isPathBoundary reports whether c cannot be part of a path segment, so a root -// immediately followed by c is a whole path rather than a prefix of a longer one. -func isPathBoundary(c byte) bool { - switch { - case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': - return false - case c == '/' || c == '.' || c == '-' || c == '_': - return false - } - return true +// IsPathSegmentByte reports whether b can be part of a path segment — a +// letter, a digit, '.', '-', '_' or the '/' separator — so a root that starts +// straight after such a byte is the tail of a longer path rather than a path +// of its own. It is the one statement of the leading boundary: RedactRoot reads +// it here and the scanner's home detectors import it (iss-2608292037564347). +func IsPathSegmentByte(b byte) bool { + return b == '/' || b == '.' || b == '-' || b == '_' || + (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') +} + +// NameContinues reports whether the name ending at byte offset end of s goes +// on: a letter or a digit at end continues it, and nothing else does. It is the +// one statement of the trailing boundary, shared by RedactRoot and the +// scanner's home sweep, detector and backstop (iss-2608292037564347). The only +// false positive a trailing anchor exists for is a longer alphanumeric name +// that starts with the root ("/rootfs" under "/root"); '.', '-' and '_' end +// the name, so "." at a sentence end, "-old" and "_snapshot" +// carry the root with a suffix and are redacted. Over-redacting a suffixed +// sibling is the safe side for an identity-bearing root. +func NameContinues(s string, end int) bool { + return end < len(s) && ((s[end] >= 'A' && s[end] <= 'Z') || (s[end] >= 'a' && s[end] <= 'z') || (s[end] >= '0' && s[end] <= '9')) } // notPresent reports whether a stat/open error means the path cannot exist: it diff --git a/internal/fsutil/redactroot_boundary_test.go b/internal/fsutil/redactroot_boundary_test.go index f7fd6b72d..b3b9ed618 100644 --- a/internal/fsutil/redactroot_boundary_test.go +++ b/internal/fsutil/redactroot_boundary_test.go @@ -71,3 +71,30 @@ func TestRedactHomeRedactsBothSpellingsOfASymlinkedHome(t *testing.T) { } } } + +// The right-hand boundary is the name rule: a root is a whole path unless a +// letter or a digit follows it, so punctuation after it — a sentence's full +// stop, a "-old" or "_snapshot" suffix — ends the root and the identity it +// carries is redacted. Only an alphanumeric continuation names a longer, +// different directory. The scanner's home sweep reads the same rule +// (fsutil.NameContinues), so the CLI error scrub, the install receipt and the +// store redactors agree about one sentence (iss-2608292037564347). +func TestRedactRootEndsTheRootAtAnyNonAlphanumericByte(t *testing.T) { + root := "/srv/qzhome" + for _, tc := range []struct{ in, want string }{ + {"cannot access /srv/qzhome.", "cannot access ~."}, + {"cannot access /srv/qzhome. Then", "cannot access ~. Then"}, + {"/srv/qzhome.old", "~.old"}, + {"/srv/qzhome-old/x", "~-old/x"}, + {"/srv/qzhome_snapshot/x", "~_snapshot/x"}, + {"/srv/qzhome/x", "~/x"}, + {"/srv/qzhome", "~"}, + // A letter or digit continues the name: a longer, different directory. + {"/srv/qzhomes/x", "/srv/qzhomes/x"}, + {"/srv/qzhome2", "/srv/qzhome2"}, + } { + if got := fsutil.RedactRoot(tc.in, root, "~"); got != tc.want { + t.Errorf("RedactRoot(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/gitutil/archive.go b/internal/gitutil/archive.go index 30ce26000..2a7572542 100644 --- a/internal/gitutil/archive.go +++ b/internal/gitutil/archive.go @@ -2,6 +2,7 @@ package gitutil import ( "errors" + "fmt" "path" "strings" ) @@ -28,16 +29,21 @@ type ArchiveEntry struct { // directory archive keeps, and the scan would miss files the tag ships. // Submodules are skipped: archive carries no submodule content. // -// Attributes are read from the index (--cached), the committed view, so an -// uncommitted .gitattributes edit does not change the answer. An error is a tree -// git could not list — no commit at rev, not a repository, git absent — and is -// never reported as an empty tree. +// Attributes are read from rev's own .gitattributes, the files git archive +// reads, so neither a working-tree edit nor a staged one changes the answer +// (iss-2609260933592838). Where the index holds exactly rev's .gitattributes +// they are asked of the index (--cached), which every git answers; where they +// differ they are asked of rev itself (--source, git 2.40 or later), and a git +// that cannot is refused by name rather than answered from the index. An error +// is a tree git could not list — no commit at rev, not a repository, git +// absent — and is never reported as an empty tree. func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { out, err := isolatedGit(root, "ls-tree", "-r", "-z", "--full-tree", "--end-of-options", rev).Output() if err != nil { return nil, withStderr(err) } var entries []ArchiveEntry + treeAttrs := map[string]string{} for _, rec := range strings.Split(string(out), "\x00") { if rec == "" { continue @@ -51,6 +57,9 @@ func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { continue // a submodule (commit) carries no content into an archive } entries = append(entries, ArchiveEntry{Path: p, Mode: fields[0]}) + if path.Base(p) == ".gitattributes" { + treeAttrs[p] = fields[0] + " " + fields[2] + } } if len(entries) == 0 { return nil, nil @@ -68,10 +77,19 @@ func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { for p := range candidates { list = append(list, p) } - cmd := isolatedGit(root, "check-attr", "--cached", "-z", "--stdin", "export-ignore") + source, err := attrSource(root, rev, treeAttrs) + if err != nil { + return nil, err + } + cmd := isolatedGit(root, "check-attr", source, "-z", "--stdin", "export-ignore") cmd.Stdin = strings.NewReader(strings.Join(list, "\x00") + "\x00") attrs, err := cmd.Output() if err != nil { + if source != "--cached" { + return nil, fmt.Errorf("the index's .gitattributes differ from %s's, and reading %s's own needs "+ + "check-attr --source (git 2.40 or later): commit or unstage the .gitattributes change (%w)", + rev, rev, withStderr(err)) + } return nil, withStderr(err) } ignored := map[string]struct{}{} @@ -97,3 +115,34 @@ func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { } return kept, nil } + +// attrSource is the check-attr option that reads rev's .gitattributes: --cached +// when the index holds exactly the .gitattributes files rev does (same paths, +// modes and blobs, none conflicted), which any git answers, and --source= +// otherwise. treeAttrs maps each .gitattributes path in rev to "mode sha". +func attrSource(root, rev string, treeAttrs map[string]string) (string, error) { + out, err := isolatedGit(root, "ls-files", "--stage", "-z", "--", ":(glob)**/.gitattributes").Output() + if err != nil { + return "", withStderr(err) + } + same := true + n := 0 + for _, rec := range strings.Split(string(out), "\x00") { + if rec == "" { + continue + } + meta, p, ok := strings.Cut(rec, "\t") + fields := strings.Fields(meta) + if !ok || len(fields) != 3 { + return "", errors.New("git ls-files returned a record it does not document: " + rec) + } + n++ + if fields[2] != "0" || treeAttrs[p] != fields[0]+" "+fields[1] { + same = false + } + } + if same && n == len(treeAttrs) { + return "--cached", nil + } + return "--source=" + rev, nil +} diff --git a/internal/gitutil/archive_test.go b/internal/gitutil/archive_test.go index ed399b5d6..d06b66fe3 100644 --- a/internal/gitutil/archive_test.go +++ b/internal/gitutil/archive_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -97,6 +98,74 @@ func TestArchiveTreeReadsAnOptionShapedRevisionAsARevision(t *testing.T) { } } +// TestArchiveTreeReadsAttributesFromTheRevision holds the listing to the +// revision's own .gitattributes, as git archive reads them, when the index +// holds a different one: a staged but uncommitted export-ignore neither drops +// a file the archive carries nor keeps one it drops (iss-2609260933592838). +func TestArchiveTreeReadsAttributesFromTheRevision(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("keep.txt", "kept\n") + r.Write("secret.txt", "ignored in the index only\n") + r.Write("dropped.txt", "ignored in the revision only\n") + r.Write("nested/n.txt", "ignored by a nested file added in the index only\n") + r.Write(".gitattributes", "dropped.txt export-ignore\n") + r.Commit("fixture") + r.Write(".gitattributes", "secret.txt export-ignore\n") + r.Write("nested/.gitattributes", "n.txt export-ignore\n") + r.Git("add", ".gitattributes", "nested/.gitattributes") + + entries, err := gitutil.ArchiveTree(r.Root(), "HEAD") + if err != nil { + if strings.Contains(err.Error(), "git 2.40") { + t.Skipf("this git reads no attributes from a revision: %v", err) + } + t.Fatal(err) + } + var got []string + for _, e := range entries { + got = append(got, e.Path) + } + sort.Strings(got) + if want := archivedFiles(t, r); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("ArchiveTree read the index's attributes, not HEAD's:\n got: %v\nwant: %v", got, want) + } + if !contains(got, "secret.txt") || !contains(got, "nested/n.txt") || contains(got, "dropped.txt") { + t.Fatalf("the fixture does not exercise both directions: %v", got) + } +} + +// TestArchiveTreeRefusesWhenItCannotReadTheRevisionsAttributes: where the index's +// .gitattributes differ from the revision's and git cannot read attributes from +// a revision (check-attr --source needs git 2.40), the listing is refused with +// the remedy, never answered from the index (iss-2609260933592838). +func TestArchiveTreeRefusesWhenItCannotReadTheRevisionsAttributes(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("keep.txt", "kept\n") + r.Write(".gitattributes", "\n") + r.Commit("fixture") + real, err := exec.LookPath("git") + if err != nil { + t.Skip("git unavailable") + } + bin := t.TempDir() + script := "#!/bin/sh\nfor a in \"$@\"; do case \"$a\" in --source=*) echo \"error: unknown option $a\" >&2; exit 129;; esac; done\nexec '" + real + "' \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + // The same attributes in the index and the revision: answered on any git. + if _, err := gitutil.ArchiveTree(r.Root(), "HEAD"); err != nil { + t.Fatalf("identical attributes need no --source: %v", err) + } + r.Write(".gitattributes", "keep.txt export-ignore\n") + r.Git("add", ".gitattributes") + _, err = gitutil.ArchiveTree(r.Root(), "HEAD") + if err == nil || !strings.Contains(err.Error(), ".gitattributes") || !strings.Contains(err.Error(), "git 2.40") { + t.Fatalf("a staged attributes change on a git without --source: err %v", err) + } +} + // archivedFiles lists the non-directory members of `git archive HEAD`. func archivedFiles(t *testing.T, r *gittest.Repo) []string { t.Helper() diff --git a/internal/gitutil/repo.go b/internal/gitutil/repo.go index c041bdabb..fee53f5e5 100644 --- a/internal/gitutil/repo.go +++ b/internal/gitutil/repo.go @@ -501,6 +501,25 @@ func Toplevel(dir string) (string, error) { return top, nil } +// RevParseAbsPath asks `git rev-parse --path-format=absolute ` for one +// path (--git-dir, --git-common-dir, --show-toplevel) and refuses any answer +// that is not exactly one absolute line. --path-format arrived in git 2.31: an +// older rev-parse echoes the option it does not know to stdout and exits 0, so +// its answer is the flag's text and a path on two lines, which a caller that +// compared it would read as a path. Refusing it keeps a resolution on an old +// git failing closed where it compares, never on a string that only looks +// like an answer. +func RevParseAbsPath(dir, flag string) (string, error) { + out, err := Run(dir, "rev-parse", "--path-format=absolute", flag) + if err != nil { + return "", err + } + if out == "" || strings.ContainsAny(out, "\r\n") || !filepath.IsAbs(out) { + return "", fmt.Errorf("git rev-parse %s named no single absolute path (--path-format needs git 2.31 or later)", flag) + } + return out, nil +} + // ToplevelShaped reports whether top has the shape of git's toplevel answer // for dir: one absolute line naming a directory that contains dir. It is // Toplevel's check, for the callers that must run git themselves (a command diff --git a/internal/surface/cli/brief_appendix_test.go b/internal/surface/cli/brief_appendix_test.go index 351a2418c..df35a3354 100644 --- a/internal/surface/cli/brief_appendix_test.go +++ b/internal/surface/cli/brief_appendix_test.go @@ -1,6 +1,8 @@ package cli import ( + "os" + "path/filepath" "strings" "testing" @@ -116,3 +118,33 @@ func TestSurfaceAppendixFromCobraTree(t *testing.T) { } } } + +// TestShippedChapterNeverClaimsNoShippedSurface: a chapter whose register row +// reads shipped never states there is no shipped surface (iss-2609231931006041). +// The host-delegated commands ship as command pages with no Go verb, and the +// committed appendix of each once said the opposite of its own register row. +func TestShippedChapterNeverClaimsNoShippedSurface(t *testing.T) { + dir := filepath.Join(testRepoRoot(), filepath.FromSlash(surface.BriefSurfacesDir)) + register, err := os.ReadFile(filepath.Join(dir, surface.RegisterFile)) + if err != nil { + t.Fatal(err) + } + checked := 0 + for _, row := range surface.ParseRegister(string(register)) { + if row.Status != "shipped" || row.Chapter == "" { + continue + } + text, err := os.ReadFile(filepath.Join(dir, row.Chapter)) + if err != nil { + t.Fatal(err) + } + checked++ + if strings.Contains(string(text), "There is no shipped surface") { + t.Errorf("%s/%s: register row %d reads shipped, but the chapter says there is no shipped surface", + surface.BriefSurfacesDir, row.Chapter, row.Line) + } + } + if checked == 0 { + t.Fatal("no shipped register rows found; the check would pass vacuously") + } +} diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index 523f0fc7b..dbf267f24 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" "testing" + "unicode/utf8" "github.com/intentdriven/abcd/internal/gittest" ) @@ -238,6 +239,61 @@ func TestCaptureListOpenRendersIssueFields(t *testing.T) { } } +// TestCaptureListOpenHumanRenderCarriesSummary is AC5's other surface +// (iss-2609240307549105): the human render of `capture list --open` carries +// each issue's one-line summary, not only the --json body. +func TestCaptureListOpenHumanRenderCarriesSummary(t *testing.T) { + _ = captureLedgerRepo(t) + runCLI(t, "capture", "the parser flakes on a trailing tab\n\nA second paragraph the row leaves out.", + "--severity", "minor", "--slug", "parser-tab") + long := "a summary long enough to be clipped " + strings.Repeat("word ", 40) + runCLI(t, "capture", long, "--severity", "minor", "--slug", "long-one") + + list := string(runCLI(t, "capture", "list", "--open")) + var row, longRow string + for _, l := range strings.Split(list, "\n") { + switch { + case strings.Contains(l, "parser-tab"): + row = l + case strings.Contains(l, "long-one"): + longRow = l + } + } + if !strings.Contains(row, "the parser flakes on a trailing tab") { + t.Fatalf("the human row carries no one-line summary:\n%s", list) + } + if strings.Contains(list, "A second paragraph") { + t.Fatalf("the human row carries more than the first line of the body:\n%s", list) + } + if !strings.Contains(longRow, "a summary long enough to be clipped") || !strings.HasSuffix(longRow, "…") || + utf8.RuneCountInString(longRow) > 200 { + t.Fatalf("a long summary is not clipped to one short line:\n%q", longRow) + } +} + +// TestSummaryNoteStripsLeadingMarkdownMarker pins that a `capture list` row's +// summary drops a leading heading or blockquote marker, so a body opening +// "# Title" reads "— Title" rather than "— # Title" (iss-2609240307549105). +// Only a CommonMark marker is stripped: a hash run followed by a space, of one +// to six hashes, or a ">" with its optional space. +func TestSummaryNoteStripsLeadingMarkdownMarker(t *testing.T) { + for _, tc := range []struct{ body, want string }{ + {"# Title\n\nmore", " — Title"}, + {"\n\n## Second level", " — Second level"}, + {"###### Six deep", " — Six deep"}, + {"> quoted line", " — quoted line"}, + {">quoted tight", " — quoted tight"}, + {"####### seven is not a heading", " — ####### seven is not a heading"}, + {"#hashtag is not a heading", " — #hashtag is not a heading"}, + {"plain first line", " — plain first line"}, + {"# ", ""}, + } { + if got := summaryNote(tc.body); got != tc.want { + t.Errorf("summaryNote(%q) = %q, want %q", tc.body, got, tc.want) + } + } +} + // TestDocsLintUnreadableConfigNoPathLeak covers a non-not-exist load failure // (the config path is a directory → EISDIR): a *PathError's Error() embeds the // absolute path, so the branch must strip it. Guards the security-review BLOCK. @@ -1519,7 +1575,9 @@ func TestCaptureWontfixRefusesALockedBody(t *testing.T) { if err == nil { t.Fatal("wontfix over a locked body acted, want a refusal") } - for _, frag := range []string{"HTML comment", "body line 2", "text editor", "re-run", "nothing written"} { + // The captured prose is the first line of the body the ledger reader + // renders; line 2 was the off-by-one count of iss-2608301908288212. + for _, frag := range []string{"HTML comment", "body line 1,", "text editor", "re-run", "nothing written"} { if !strings.Contains(err.Error(), frag) { t.Fatalf("the refusal does not name %q: %v", frag, err) } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index c265a884f..58dea356c 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -240,7 +240,7 @@ func NewRootCommand() *cobra.Command { // --agent modifies the help and nothing else; on the board it would // be a flag that silently does nothing (itd-146). if agentHelp { - return &exitError{Code: 2, Msg: "--agent expands the help listing; run `abcd --help --agent`"} + return &exitError{Code: 2, Msg: helpAgentRefusal} } // --version is where every tool keeps its version // (itd-2609212130136102). It answers alone: a record id beside it @@ -552,6 +552,9 @@ func NewRootCommand() *cobra.Command { // two operands the design admits, because the operand it most often refuses // is one it used to take (adr-2609021016286571). applyReadingFlagErrors(root) + // Also after the generic tagging: cobra's help verb inherits the root's + // flag-error function, and `abcd help --agent` names the spelling that works. + applyHelpVerbAgentRefusal(root) // Also after the generic tagging, and last: on the hook plane exit 2 is the // host's instruction to BLOCK, so every usage error a hook can provoke refuses // at exit 1 instead (iss-269). @@ -2953,6 +2956,14 @@ func runIssueDrift(cmd *cobra.Command, asJSON, strict bool) error { if err != nil { return err } + // The check reads this checkout's issue ledger, so it names the ledger it + // read as every capture verb does (iss-2609251235119402): on stderr before + // the read in the text render, as the `ledger` member under --json. + if !asJSON { + id := ledgerIdentityOf(repoRoot) + fmt.Fprintf(cmd.ErrOrStderr(), "abcd intent audit --issue-drift: ledger of %s%s\n", + termsafe.Sanitize(id.Checkout), branchPhrase(id.Branch)) + } res, err := capture.IssueDrift(capture.IssueDriftRequest{RepoRoot: repoRoot}) if err != nil { return &exitError{Code: 2, Msg: "abcd intent audit --issue-drift: " + err.Error()} @@ -2966,7 +2977,7 @@ func runIssueDrift(cmd *cobra.Command, asJSON, strict bool) error { fmt.Fprintf(cmd.ErrOrStderr(), "warning: issue-drift %s %s -> %s (%s): %s\n", f.Kind, f.Record, f.Other, termsafe.Sanitize(f.Path), termsafe.Sanitize(f.Message)) } - if err := render(cmd.OutOrStdout(), asJSON, res, func(w io.Writer) { + if err := renderLedger(cmd.OutOrStdout(), asJSON, repoRoot, res, func(w io.Writer) { fmt.Fprintf(w, "abcd intent audit --issue-drift — %d record(s) scanned, %d finding(s) (receipt %s)\n", res.Scanned, len(res.Findings), termsafe.Sanitize(res.ReceiptPath)) }); err != nil { @@ -4354,7 +4365,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { } return renderLedger(cmd.OutOrStdout(), *asJSON, repoRoot, res, func(w io.Writer) { for _, iss := range res.Issues { - fmt.Fprintf(w, "%s %s %s %s%s\n", iss.ID, iss.Status, iss.Severity, iss.Slug, blockedNote(iss)) + fmt.Fprintf(w, "%s %s %s %s%s%s\n", iss.ID, iss.Status, iss.Severity, iss.Slug, blockedNote(iss), summaryNote(iss.Body)) } for _, sk := range res.Skipped { // Path and Error echo a malformed issue file's own name and content @@ -5303,6 +5314,50 @@ func blockedNote(iss capture.Issue) string { return " [blocked-by " + strings.Join(iss.BlockedByOpen, ",") + "]" } +// listSummaryRunes caps the one-line summary a `capture list` row carries. +const listSummaryRunes = 80 + +// summaryNote renders the tail of a `capture list` row: the first non-blank +// line of the record's body, less a leading markdown heading or blockquote +// marker, sanitised for the terminal and clipped to listSummaryRunes, so each +// row carries the one-line summary itd-4's AC5 names and stays one line. The +// whole body is in --json. +func summaryNote(body string) string { + var line string + for _, l := range strings.Split(body, "\n") { + if l = strings.TrimSpace(l); l != "" { + line = l + break + } + } + line = strings.TrimSpace(termsafe.Sanitize(stripMarkdownMarker(line))) + if line == "" { + return "" + } + if r := []rune(line); len(r) > listSummaryRunes { + line = strings.TrimSpace(string(r[:listSummaryRunes])) + "…" + } + return " — " + line +} + +// stripMarkdownMarker removes one leading CommonMark marker from a trimmed +// line: an ATX heading's run of one to six "#" when a space, a tab or the end +// of the line follows it, or a blockquote's ">" with its optional space. A +// longer hash run or one glued to a word is text, and is left alone. +func stripMarkdownMarker(line string) string { + if strings.HasPrefix(line, ">") { + return strings.TrimPrefix(line[1:], " ") + } + n := len(line) - len(strings.TrimLeft(line, "#")) + if n == 0 || n > 6 { + return line + } + if rest := line[n:]; rest == "" || rest[0] == ' ' || rest[0] == '\t' { + return rest + } + return line +} + // moreEvidenceNote renders the tail of a `capture mentions` row: the render shows // the row's FIRST evidence in full and says how many others named the same // record, so a row stays one line and nothing is silently dropped. First means diff --git a/internal/surface/cli/helpgroups.go b/internal/surface/cli/helpgroups.go index b90e42082..dd486e9fc 100644 --- a/internal/surface/cli/helpgroups.go +++ b/internal/surface/cli/helpgroups.go @@ -59,6 +59,10 @@ const ( // helpAgentsTitle heads the agents-and-hosts block. It is also the cobra // title of groupAgents, so the one list has one name. helpAgentsTitle = "For agents and hosts (each line names the page to read next):" + // helpAgentRefusal is the refusal of --agent anywhere but beside --help: + // on the bare board and on the help verb alike, it names the spelling that + // works. + helpAgentRefusal = "--agent expands the help listing; run `abcd --help --agent`" ) // Annotation keys the placement writes onto a command. @@ -167,6 +171,24 @@ func applyHelpPlacement(root *cobra.Command, agent *bool) { }) } +// applyHelpVerbAgentRefusal answers `abcd help --agent` with the refusal the +// bare `abcd --agent` gives, naming the spelling that works, instead of cobra's +// bare "unknown flag: --agent" (iss-2609251645376019). cobra builds its help +// verb inside Execute, after every flag-error function in the tree is set, so +// the verb inherits the root's; the root's is wrapped here, after the generic +// tagging, and passes every other error through unchanged. +func applyHelpVerbAgentRefusal(root *cobra.Command) { + inner := root.FlagErrorFunc() + root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if cmd != root && cmd.Parent() == root && cmd.Name() == "help" { + if m := unknownFlagRe.FindStringSubmatch(err.Error()); m != nil && m[1] == "--agent" { + return &exitError{Code: 2, Msg: helpAgentRefusal} + } + } + return inner(cmd, err) + }) +} + func annotate(cmd *cobra.Command, key, value string) { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} @@ -272,9 +294,12 @@ func renderRootHelp(w io.Writer, root *cobra.Command, agent bool) { if agent { agents = agentEntries(root) } - width := 0 + // Each block is sized over its own names, so the person's block reads byte + // for byte the same whether or not --agent adds the second one + // (iss-2609251645374557). + width, agentWidth := 0, 0 for _, e := range agents { - width = max(width, len(e.name)) + agentWidth = max(agentWidth, len(e.name)) } for _, entries := range groups { for _, e := range entries { @@ -310,7 +335,7 @@ func renderRootHelp(w io.Writer, root *cobra.Command, agent bool) { if agent { fmt.Fprintf(w, "%s\n", helpAgentsTitle) for _, e := range agents { - fmt.Fprintf(w, " %-*s %s (read %s)\n", width, e.name, e.short, e.page) + fmt.Fprintf(w, " %-*s %s (read %s)\n", agentWidth, e.name, e.short, e.page) } fmt.Fprintln(w) } diff --git a/internal/surface/cli/helpgroups_test.go b/internal/surface/cli/helpgroups_test.go index f01507e49..6cfac795e 100644 --- a/internal/surface/cli/helpgroups_test.go +++ b/internal/surface/cli/helpgroups_test.go @@ -271,6 +271,62 @@ func TestAgentFlagOutsideHelpRefuses(t *testing.T) { } } +// TestAgentFlagOnTheHelpVerbNamesTheSpellingThatWorks is iss-2609251645376019: +// `abcd help --agent` reached cobra's bare "unknown flag: --agent", while the +// root's own refusal of --agent names `abcd --help --agent`. The help verb's +// path names the same spelling, and stays a usage error. +func TestAgentFlagOnTheHelpVerbNamesTheSpellingThatWorks(t *testing.T) { + for _, args := range [][]string{{"help", "--agent"}, {"help", "lint", "--agent"}} { + out, err := runCLIErr(t, args...) + if err == nil { + t.Fatalf("`abcd %s` must refuse:\n%s", strings.Join(args, " "), out) + } + if code := exitCodeOf(err); code != 2 { + t.Errorf("`abcd %s` exit = %d, want 2 (a usage error)", strings.Join(args, " "), code) + } + if !strings.Contains(err.Error(), "abcd --help --agent") { + t.Errorf("`abcd %s` must name the spelling that works, got %v", strings.Join(args, " "), err) + } + } +} + +// helpPeopleBlock is the person's groups as rendered: every line from the first +// group title to the line before the agents block or the flags. +func helpPeopleBlock(help string) string { + lines := strings.Split(help, "\n") + start, end := -1, len(lines) + for i, line := range lines { + if start < 0 && line == peopleGroupTitles[0] { + start = i + } + if start >= 0 && (line == helpAgentsTitle || line == "Flags:") { + end = i + break + } + } + if start < 0 { + return "" + } + return strings.Join(lines[start:end], "\n") +} + +// TestPeopleBlockIsTheSameUnderBothHelps is iss-2609251645374557: the name +// column was sized over both blocks, so the person's groups padded to 12 under +// --help and to the agent block's longest path under --help --agent. Each block +// is sized over its own names, so the person's block reads byte for byte the +// same in both forms. +func TestPeopleBlockIsTheSameUnderBothHelps(t *testing.T) { + plain, _ := executedHelp(t, "--help") + agent, _ := executedHelp(t, "--help", "--agent") + p, a := helpPeopleBlock(plain), helpPeopleBlock(agent) + if p == "" { + t.Fatalf("no person's block in the plain help:\n%s", plain) + } + if p != a { + t.Errorf("the person's block differs between --help and --help --agent:\n--- --help\n%s\n--- --help --agent\n%s", p, a) + } +} + // TestCommandPagesDeclareTheirBlock is criterion 5 on the page side: every // command page backing a visible top-level verb says in its frontmatter which // help block lists that verb, and says the one the tree says. diff --git a/internal/surface/cli/hooks_selfprovision_test.go b/internal/surface/cli/hooks_selfprovision_test.go index 2876b41e2..2633373de 100644 --- a/internal/surface/cli/hooks_selfprovision_test.go +++ b/internal/surface/cli/hooks_selfprovision_test.go @@ -577,6 +577,7 @@ var pathRefusalReasons = []string{ "it did not resolve to an absolute path", "its directory could not be resolved", "its directory is world-writable", + pathRefusalWritableBinary, pathRefusalUnowned, pathRefusalUnownedRecord, pathRefusalSymlinkedHome, @@ -721,6 +722,49 @@ func TestBinaryHooksRefuseAWorldWritablePathBinary(t *testing.T) { } } +// pathRefusalWritableBinary is the refusal of a binary whose OWN mode lets +// every local user rewrite it, whatever directory it sits in. +const pathRefusalWritableBinary = "the binary itself is world-writable" + +// TestBinaryHooksRefuseAWorldWritablePathBinaryFile: the rung judged only the +// directory the PATH entry lives in, so a 0777 binary in an ordinary 0755 +// directory — recorded, owned, outside the tree — passed every check, and any +// local user could replace the bytes every prompt and tool call then executes +// (iss-2609020352438590, shape 2). The file's own mode is judged too, through a +// symlink to the file it names, because that is the file the hook executes: a +// link in an ordinary directory must not launder a writable target. +func TestBinaryHooksRefuseAWorldWritablePathBinaryFile(t *testing.T) { + for _, shape := range []string{"file", "symlink"} { + t.Run(shape, func(t *testing.T) { + for _, h := range binaryHooks { + t.Run(h.event, func(t *testing.T) { + root := hookRoot(t, failingBootstrap, false) + pathDir := t.TempDir() + home := t.TempDir() + target := pathDir + if shape == "symlink" { + target = t.TempDir() + } + pathStub(t, target) + if err := os.Chmod(filepath.Join(target, "abcd"), 0o777); err != nil { + t.Fatal(err) + } + if shape == "symlink" { + if err := os.Symlink(filepath.Join(target, "abcd"), filepath.Join(pathDir, "abcd")); err != nil { + t.Fatal(err) + } + } + writeHookPathEntry(t, home, filepath.Join(pathDir, "abcd")) + _, stderr, code := hookRunHome(t, h.event, root, pathDir, t.TempDir(), home) + assertPathBinaryRefused(t, h, root, stderr, code, + []string{filepath.Join(pathDir, "abcd")}, + []string{pathRefusalWritableBinary}) + }) + } + }) + } +} + // TestBinaryHooksRefuseAPathBinaryVouchedForByAnUnownedRecord is the first // acceptance criterion of iss-2609091927085132. The shim carefully establishes // that the candidate binary resolves absolutely, sits outside the working tree diff --git a/internal/surface/cli/ideate.go b/internal/surface/cli/ideate.go index 3c0899bef..46f70ca55 100644 --- a/internal/surface/cli/ideate.go +++ b/internal/surface/cli/ideate.go @@ -167,9 +167,15 @@ func renderIdeateResult(res ideate.Result) string { out += fmt.Sprintf(" redacted: %d secret/PII span(s) rewritten out of the verdict text before it was recorded\n", res.Redactions) } - if res.Graduates { + // One line per verdict: a reframed idea survives in another shape, so its + // line must not read as the killed one's (iss-2609100508573400). + switch { + case res.Graduates: out += " next: the idea may graduate to a draft intent — `abcd intent \"\"`\n" - } else { + case res.Verdict == ideate.VerdictReframed: + out += " next: the idea as posed does not graduate, but its reframing may — " + + "`abcd intent \"\"` with the reframing the record carries, not the original wording\n" + default: out += " next: the idea does not graduate; the record is why\n" } return out diff --git a/internal/surface/cli/ideate_surface_test.go b/internal/surface/cli/ideate_surface_test.go index 8cd153c7e..55f26b497 100644 --- a/internal/surface/cli/ideate_surface_test.go +++ b/internal/surface/cli/ideate_surface_test.go @@ -200,3 +200,32 @@ func TestIdeateRoutingHintIsAPointerNotAGate(t *testing.T) { t.Errorf("capture was blocked: %v\n%s", err, out) } } + +// TestIdeateRenderSaysWhatEachVerdictLeavesNext is iss-2609100508573400: a +// reframed idea survives in another shape, so its next line must not read as +// the killed one's. Each verdict gets its own line, and the reframed one names +// the reframing as what may graduate. +func TestIdeateRenderSaysWhatEachVerdictLeavesNext(t *testing.T) { + next := func(v ideate.Verdict) string { + out := renderIdeateResult(ideate.Result{Slug: "x", Verdict: v, Graduates: v == ideate.VerdictSurvives}) + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "next:") { + return line + } + } + t.Fatalf("no next line for %s:\n%s", v, out) + return "" + } + killed, reframed, survives := next(ideate.VerdictKilled), next(ideate.VerdictReframed), next(ideate.VerdictSurvives) + if reframed == killed || reframed == survives { + t.Errorf("the reframed verdict borrows another verdict's next line: %q", reframed) + } + for _, want := range []string{"reframing", "abcd intent"} { + if !strings.Contains(reframed, want) { + t.Errorf("the reframed next line must name %q: %q", want, reframed) + } + } + if strings.Contains(reframed, "does not graduate;") { + t.Errorf("the reframed next line reads as the killed one: %q", reframed) + } +} diff --git a/internal/surface/cli/issue_drift_surface_test.go b/internal/surface/cli/issue_drift_surface_test.go index 777d730e6..f2521bf01 100644 --- a/internal/surface/cli/issue_drift_surface_test.go +++ b/internal/surface/cli/issue_drift_surface_test.go @@ -110,3 +110,37 @@ func TestCaptureMigrateReportsThenApplies(t *testing.T) { t.Fatalf("the migrated record must read back:\n%s", list) } } + +// TestIntentAuditIssueDriftNamesTheLedgerItRead is iss-2609251235119402: the +// drift check reads the issue ledger, which is per checkout, so it names the +// checkout and branch it read the way every capture verb does — on stderr in +// the text render, as the envelope's `ledger` member under --json. +func TestIntentAuditIssueDriftNamesTheLedgerItRead(t *testing.T) { + repo := oneSidedRepo(t) + gitCommitAt(t, repo, "root") + + _, stderr, err := runCLISplit(t, "intent", "audit", "--issue-drift") + if err != nil { + t.Fatalf("the default mode must exit 0: %v\n%s", err, stderr) + } + if !strings.Contains(stderr, "abcd intent audit --issue-drift: ledger of ") || !strings.Contains(stderr, "on branch main") { + t.Fatalf("the text render does not name the ledger it read:\n%s", stderr) + } + + out, err := runCLIErr(t, "intent", "audit", "--issue-drift", "--json") + if err != nil { + t.Fatalf("--json: %v\n%s", err, out) + } + var env struct { + Ledger struct { + Checkout string `json:"checkout"` + Branch string `json:"branch"` + } `json:"ledger"` + } + if err := json.Unmarshal(out, &env); err != nil { + t.Fatalf("--json output is not one envelope: %v\n%s", err, out) + } + if env.Ledger.Branch != "main" || !strings.HasSuffix(env.Ledger.Checkout, filepath.Base(repo)) { + t.Fatalf("ledger member = %+v, want the checkout %s on main", env.Ledger, filepath.Base(repo)) + } +} diff --git a/internal/surface/cli/reading.go b/internal/surface/cli/reading.go index f95439d19..a02f04443 100644 --- a/internal/surface/cli/reading.go +++ b/internal/surface/cli/reading.go @@ -238,9 +238,15 @@ func newReadingCommand(asJSON *bool) *cobra.Command { // bytes go to the ingest that validates them and to the receipt's // model: what was routed and reported is what was ingested. A read // that fails hands the ingest nothing, and its own read refuses the - // output with its own reason. + // output with its own reason. Under a --route the read's reason is + // the refusal: an unread output names no position, and saying so + // would blame the route for a size cap or a symlink + // (iss-2609251606553359). payload, rerr := reading.ReadOutput(resolved) if rerr != nil { + if len(readingRoute.texts) > 0 { + return readingRefusal("reading ingest", rerr) + } payload = nil } route, err := readingIngestRoute(cmd, readingRoute, payload) diff --git a/internal/surface/cli/route.go b/internal/surface/cli/route.go index c51e0eda1..a452fdfa0 100644 --- a/internal/surface/cli/route.go +++ b/internal/surface/cli/route.go @@ -105,6 +105,10 @@ func tierHelp() string { // table is read, so a verb's non-delegating modes are untouched by routing. func (rf *routeFlag) resolve(cmd *cobra.Command, verb, agent string) (*oracle.Route, error) { stderr := cmd.ErrOrStderr() + // Callers name the verb both bare ("disembark review") and already + // prefixed ("abcd intent audit"); the stderr lines name it exactly once + // (iss-2609251606543515). + label := "abcd " + strings.TrimPrefix(verb, "abcd ") if agent == "" { if len(rf.texts) > 0 { return nil, &exitError{Code: 2, Msg: verb + ": --route routes an agent this step dispatches, and this " + @@ -127,7 +131,7 @@ func (rf *routeFlag) resolve(cmd *cobra.Command, verb, agent string) (*oracle.Ro "; the routing table decides which model this step asks for, so it is refused rather than guessed past — fix or remove the file"} } for _, d := range l.Diagnostics { - fmt.Fprintf(stderr, "abcd %s: %s\n", verb, termsafe.Sanitize(d)) + fmt.Fprintf(stderr, "%s: %s\n", label, termsafe.Sanitize(d)) } routes, err := oracle.ParseRoutes(rf.texts, []string{agent}, conns) if err != nil { @@ -141,7 +145,7 @@ func (rf *routeFlag) resolve(cmd *cobra.Command, verb, agent string) (*oracle.Ro return nil, &exitError{Code: 2, Msg: verb + ": " + termsafe.Sanitize(fsutil.RedactHome(err.Error()))} } if r.Fallback != "" { - fmt.Fprintf(stderr, "abcd %s: %s\n", verb, termsafe.Sanitize(r.Fallback)) + fmt.Fprintf(stderr, "%s: %s\n", label, termsafe.Sanitize(r.Fallback)) } return &r, nil } diff --git a/internal/surface/cli/route_audit_test.go b/internal/surface/cli/route_audit_test.go index 5ab6b63eb..ec3346ee4 100644 --- a/internal/surface/cli/route_audit_test.go +++ b/internal/surface/cli/route_audit_test.go @@ -193,3 +193,19 @@ func TestIntentAuditListingRefusesRoute(t *testing.T) { t.Fatalf("err %v", err) } } + +// TestRouteStderrNamesTheVerbOnce: a caller that hands resolve an +// already-prefixed verb ("abcd intent audit") gets its fallback line named +// once, not "abcd abcd intent audit" (iss-2609251606543515). +func TestRouteStderrNamesTheVerbOnce(t *testing.T) { + root := intentTestRepo(t) + writeRepoFile(t, root, ".abcd/development/intents/shipped/itd-10-alpha.md", conditionedIntent) + acceptRepoRow(t, root, "intent-auditor", "frontier") + _, stderr, err := runCLISplit(t, "intent", "audit", "itd-10", "--json") + if err != nil { + t.Fatal(err) + } + if strings.Contains(stderr, "abcd abcd") || !strings.HasPrefix(stderr, "abcd intent audit: ") { + t.Fatalf("stderr %q", stderr) + } +} diff --git a/internal/surface/cli/route_reading_test.go b/internal/surface/cli/route_reading_test.go index 77d011de3..183da98fa 100644 --- a/internal/surface/cli/route_reading_test.go +++ b/internal/surface/cli/route_reading_test.go @@ -1,10 +1,13 @@ package cli import ( + "os" + "path/filepath" "strings" "testing" "github.com/intentdriven/abcd/internal/core/oracle" + "github.com/intentdriven/abcd/internal/core/reading" ) // TestReadingIngestCarriesTheReceipt is step 4's golden receipt for `reading @@ -49,3 +52,25 @@ func TestReadingIngestCarriesTheReceipt(t *testing.T) { t.Fatalf("route = %+v", rc) } } + +// TestReadingIngestRouteOnAnUnreadableOutputGivesTheReadsReason: an output the +// ingest cannot read (here, one past the size cap) is refused for that reason +// with or without a --route, never as an output that "names no reading +// position" (iss-2609251606553359). +func TestReadingIngestRouteOnAnUnreadableOutputGivesTheReadsReason(t *testing.T) { + repo := readingRepo(t) + t.Setenv("HOME", t.TempDir()) + t.Chdir(repo) + big := filepath.Join(t.TempDir(), "big.json") + if err := os.WriteFile(big, make([]byte, reading.MaxFileBytes+1), 0o600); err != nil { + t.Fatal(err) + } + for _, routes := range [][]string{nil, {"--route", "cold-reading-detection=economy"}} { + args := append([]string{"reading", "ingest", "--reading-json", big}, routes...) + _, _, err := runCLISplit(t, args...) + if exitCodeOf(err) != 2 || !strings.Contains(err.Error(), "-byte cap") || + strings.Contains(err.Error(), "names no reading position") { + t.Fatalf("%v: err %v", routes, err) + } + } +} diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index f13d6b54c..084a8ceaa 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -474,7 +474,21 @@ func runShipIngest(cmd *cobra.Command, cwd string, raw []byte, payloadDir string res shipResult preflightNote string ) + // A release already in flight is the cut's own refusal (release-in-flight, + // exit 1), and that is the one to show. The pre-flight's parity diff would + // refuse the same window first, as a baseline this checkout cannot read, and + // name --baseline, a flag this verb does not take (iss-2609252117203691). The + // ingest below refuses without writing, so skipping the pre-flight keeps + // nothing from a cut that could have landed. + precheck := stage if stage { + inFlight, _, err := changelog.ReleaseInFlight(cwd) + if err != nil { + return &exitError{Code: 2, Msg: "abcd launch ship: " + scrubPaths(err)} + } + precheck = !inFlight + } + if precheck { if archive { scratch, err := os.MkdirTemp("", "abcd-ship-") if err != nil { diff --git a/internal/surface/cli/ship_payload_test.go b/internal/surface/cli/ship_payload_test.go index b5e914eb0..ed629d460 100644 --- a/internal/surface/cli/ship_payload_test.go +++ b/internal/surface/cli/ship_payload_test.go @@ -367,3 +367,34 @@ func TestLaunchDryRunSanitisesRefusalReasons(t *testing.T) { t.Errorf("dry-run output leaked a raw ESC from a repo filename:\n%q", out) } } + +// TestLaunchShipInTheShipToTagWindowRefusesAsInFlight is iss-2609252117203691: +// a re-run of a rendering ship after the cut landed and before its tag refused +// on the pre-flight's parity diff, whose reason named --baseline, a flag launch +// ship does not take. The window is a release in flight, and the ship says so +// the way the emit step does: the cut's own refusal, exit 1, nothing written. +func TestLaunchShipInTheShipToTagWindowRefusesAsInFlight(t *testing.T) { + r := shipRenderableRepo(t) + r.Write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n## [0.4.1] - 2026-07-20\n\n### Added\n\n- the cut that landed.\n\n"+ + "## [0.4.0] - 2026-07-01\n\n### Added\n\n- the base.\n") + r.Commit("the ship PR merged; auto-release has not tagged it yet") + before := readFileString(t, filepath.Join(r.Root(), "CHANGELOG.md")) + + dest := filepath.Join(t.TempDir(), "payload") + payload := composedPayload(t, t.TempDir(), "v0.4.2", "itd-73") + out, err := shipIn(t, r, "launch", "ship", "--changelog-json", payload, "--payload-dir", dest) + if code := exitCodeOf(err); code != 1 { + t.Fatalf("exit = %d, want 1 (the cut's in-flight refusal): %v\n%s", code, err, out) + } + for _, want := range []string{"release-in-flight", "v0.4.1"} { + if !strings.Contains(string(out), want) { + t.Errorf("the refusal does not mention %q:\n%s", want, out) + } + } + if strings.Contains(string(out), "--baseline") || (err != nil && strings.Contains(err.Error(), "--baseline")) { + t.Errorf("the ship's refusal names --baseline, a flag launch ship does not take: %v\n%s", err, out) + } + if after := readFileString(t, filepath.Join(r.Root(), "CHANGELOG.md")); after != before { + t.Error("a refused ship must write nothing") + } +} diff --git a/internal/surface/cli/site.go b/internal/surface/cli/site.go index 93964e6ed..f50200503 100644 --- a/internal/surface/cli/site.go +++ b/internal/surface/cli/site.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -30,11 +31,11 @@ func newSiteCommand(asJSON *bool) *cobra.Command { var statusOut string siteCmd.RunE = func(cmd *cobra.Command, _ []string) error { - cwd, err := os.Getwd() + root, out, err := siteRootAndOut(cmd, statusOut) if err != nil { return err } - st, err := site.Describe(cwd, statusOut) + st, err := site.Describe(root, out) if err != nil { return &exitError{Code: 2, Msg: "abcd site: " + scrubPaths(err)} } @@ -51,13 +52,13 @@ func newSiteCommand(asJSON *bool) *cobra.Command { Use: "build", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - cwd, err := os.Getwd() + root, out, err := siteRootAndOut(cmd, buildOut) if err != nil { return err } res, err := site.Build(site.Request{ - RepoRoot: cwd, - OutDir: buildOut, + RepoRoot: root, + OutDir: out, Stamp: site.BuildStamp{Version: version, Commit: commit, GeneratedAt: stampDate, Preview: preview}, }) if err != nil { @@ -85,6 +86,26 @@ func newSiteCommand(asJSON *bool) *cobra.Command { return siteCmd } +// siteRootAndOut resolves what a site verb reads and where it writes. The site +// is a fact about the repository, not about the directory the operator stands +// in, so the root is the checkout's, as the other per-repository verbs resolve +// it (iss-2609251750202525). The default output directory is the checkout's +// too; an --out the operator names means what the shell means by it. +func siteRootAndOut(cmd *cobra.Command, out string) (string, string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", "", err + } + root := captureRoot(cwd) + switch { + case !cmd.Flags().Changed("out"): + out = filepath.Join(root, site.DefaultOutDir) + case out != "" && !filepath.IsAbs(out): + out = filepath.Join(cwd, out) + } + return root, out, nil +} + // newLintSiteCommand builds `lint site`: the gates adr-47 decision 3 arms, run // over a built output directory, rendering it first when it holds no // index.html. It exits 1 when any gate fails, so a release job can stop on it. @@ -94,11 +115,11 @@ func newLintSiteCommand(asJSON *bool) *cobra.Command { Use: "site", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - cwd, err := os.Getwd() + root, out, err := siteRootAndOut(cmd, checkOut) if err != nil { return err } - res, err := site.Check(site.CheckRequest{RepoRoot: cwd, OutDir: checkOut}) + res, err := site.Check(site.CheckRequest{RepoRoot: root, OutDir: out}) if err != nil { return &exitError{Code: 2, Msg: "abcd lint site: " + scrubPaths(err)} } diff --git a/internal/surface/cli/site_setup_test.go b/internal/surface/cli/site_setup_test.go index 72cbeb3ad..1f0b0bdbf 100644 --- a/internal/surface/cli/site_setup_test.go +++ b/internal/surface/cli/site_setup_test.go @@ -129,3 +129,47 @@ func TestSiteSetupTextAlignsEveryStatus(t *testing.T) { t.Fatalf("%d of %d names rendered:\n%s", seen, len(names), buf.String()) } } + +// TestSiteVerbsReadTheCheckoutFromASubdirectory: `abcd site`, `site build` and +// `lint site` read the repository the working directory sits in, not the +// working directory itself, so run from a subdirectory they report and build +// the same site as from the root (iss-2609251750202525). +func TestSiteVerbsReadTheCheckoutFromASubdirectory(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + r := gittest.NewRepo(t) + r.Write("AGENTS.md", "# Example\n\n\nmanaged\n\n") + r.Write(".abcd/positioning.json", `{"schema_version": 1, "block": {"file": ".abcd/development/IDENTITY.md", "heading": "Identity (canonical)"}, "severity": "warn", "surfaces": []}`+"\n") + r.Write(".abcd/development/IDENTITY.md", "# Identity\n\n## Identity (canonical)\n\n- **Title:** Example\n- **Tagline:** An example.\n") + r.Write("docs/README.md", "# Example\n\nThe example's documentation.\n") + r.Commit("the example") + t.Chdir(r.Root()) + if out, err := runCLIErr(t, "--json", "site", "setup", "--name", "example-site"); err != nil { + t.Fatalf("site setup: %v\n%s", err, out) + } + atRoot, err := runCLIErr(t, "--json", "site") + if err != nil { + t.Fatalf("site at the root: %v\n%s", err, atRoot) + } + t.Chdir(filepath.Join(r.Root(), "docs")) + inSub, err := runCLIErr(t, "--json", "site") + if err != nil { + t.Fatalf("site in a subdirectory: %v\n%s", err, inSub) + } + if string(inSub) != string(atRoot) { + t.Fatalf("the board differs by working directory:\nroot: %s\nsub: %s", atRoot, inSub) + } + + // The default output directory is the checkout's, wherever the build runs. + if out, err := runCLIErr(t, "site", "build"); err != nil { + t.Fatalf("site build in a subdirectory: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(r.Root(), "site", "index.html")); err != nil { + t.Fatalf("the build did not land in the checkout's site directory: %v", err) + } + if _, err := os.Stat(filepath.Join(r.Root(), "docs", "site")); !os.IsNotExist(err) { + t.Fatalf("the build wrote beside the working directory: %v", err) + } + if out, err := runCLIErr(t, "lint", "site"); exitCodeOf(err) == 2 { + t.Fatalf("lint site in a subdirectory refused: %v\n%s", err, out) + } +} diff --git a/internal/termsafe/describe.go b/internal/termsafe/describe.go new file mode 100644 index 000000000..a8c810f99 --- /dev/null +++ b/internal/termsafe/describe.go @@ -0,0 +1,18 @@ +package termsafe + +import "strconv" + +// DescribeRefused says what a refused closed-set value looks like without +// quoting it: its length, or that it is empty. It is for a refusal of a value +// that arrived in a host-composed payload and was not redacted on the way in — +// an enum member, a version string, a mode — where quoting it would carry +// whatever was pasted there (a token, a home path) into the terminal, a log and +// the session transcript. Sanitize is no substitute: it strips control +// sequences and redacts nothing. The length and the listed set beside it are +// enough to find a typo. +func DescribeRefused(value string) string { + if value == "" { + return "an empty value" + } + return "a " + strconv.Itoa(len(value)) + "-byte value, not quoted" +} diff --git a/internal/termsafe/describe_test.go b/internal/termsafe/describe_test.go new file mode 100644 index 000000000..3358a49f2 --- /dev/null +++ b/internal/termsafe/describe_test.go @@ -0,0 +1,17 @@ +package termsafe + +import ( + "strings" + "testing" +) + +func TestDescribeRefusedNeverQuotes(t *testing.T) { + if got := DescribeRefused(""); got != "an empty value" { + t.Errorf("DescribeRefused(\"\") = %q", got) + } + const v = "zzleak-7f3a" + got := DescribeRefused(v) + if strings.Contains(got, v) || !strings.Contains(got, "11-byte") { + t.Errorf("DescribeRefused(%q) = %q, want the length and not the value", v, got) + } +}