feat: implement itd-150..162 against specs spc-43..54 - #555
Merged
Conversation
…fact The secret/PII scanner ran every adjacency probe against a fixed 512-byte slice of the line, so the slice's end was indistinguishable from the end of the line to the regexp engine. Two failures followed from that one artefact. A probe's own trailing \b was satisfied by the artificial edge, reporting a LAN hostname the full line does not contain and over-redacting hundreds of bytes of legitimate content (iss-189). And an open-ended token longer than the window was recorded with an artificial end, so the next probe and the next junction search both started mid-token and the chain that unwinds a run of abutting tokens broke — the third token vanished entirely rather than being truncated (iss-190). gallopingFind replaces the single fixed slice with an exponential-doubling probe. The window grows only while a match is still running into its own edge, and stops the moment the answer can no longer change: the match ended short of the edge, the window already reaches the real end of the line, or nothing matched inside a bounded window. That last stop is the cost bound the fixed window was introduced for and is deliberately kept — a FAILING attempt is what two bundled patterns' unbounded internal quantifiers make expensive. Growing the window for a match that is genuinely still running does not cost a class: doubling sums to under twice the final window and the final window is under twice the match, so one probe costs a constant multiple of that match's OWN length, the class the top-level unbounded match already pays. No boundary classifier and no clipped-so-skip branch: removing the artificial edge dissolves both questions instead of adjudicating them. maxAdjacencyBacktrack, the BACKWARD bound, is untouched; only the forward extent gallops, in probeAt and in the junction search's forward reach. The cost class is asserted, not hoped for: gallopingFind takes a matcher interface so the guard can hand it a counting stand-in and pin the schedule exactly — one fixed-window attempt for a short match and for a non-match, a logarithmic number of attempts totalling under 4x the match for a long one — alongside a wall-clock guard over the shapes purpose-built to make it grow. Assisted-by: Claude:claude-opus-5[1m]
…nance Resolves: iss-229 Assisted-by: Claude:claude-opus-5[1m]
agents/ holds the prompts a model reads as instruction, and it sat outside both lint roots — so agents/README.md documented the itd-5 contract and named its linter as unbuilt, while five prompts that read the most attacker-influenceable input in the tree acquired the contract by hand and nothing checked that the sixth would. agent_contract is that detector. It walks agents/ directly (a prompt is not a record, so the tree is not added to cfg.Roots and judged by the record stores' schema) and holds each prompt to three things: the trust-contract frontmatter, the injection-canary fixture an untrusted-input prompt must ship, and — over a diff — the per-agent CHANGELOG entry. The reads_untrusted_input DECLARATION is required of every prompt, not only of the ones that admit to reading untrusted input: a rule that fires on `true` alone is a rule the next prompt opts out of by deleting a line, and an undeclared prompt is exactly the class this exists to catch. The changelog sub-check is diff-scoped because it asks whether a CHANGE announced itself. The range is armed by the caller (`record-lint -agent-diff`, ArmAgentDiff), never read from the in-tree config — the ArmReceiptGate reasoning: a gate a committer can point at an empty range is a gate a committer can disarm. Unarmed, it is a no-op and the tree-shaped halves still run. Resolves: iss-278 Assisted-by: Claude:claude-opus-5[1m]
record_schema reasons across the stores but only INSIDE them, so a markdown
file outside every store is not a malformed record to the engine — it is not a
record at all. A note could head itself `# ADR-23`, declare itself accepted,
reuse an id a real ADR already holds, and pass the whole gate at exit 0 with
zero findings. That was measured by probe, and the known instance survived a
full architecture change.
cross_store_id_claim walks the markdown outside the configured stores and fires
on a PAIR of signals: an H1 that OPENS with a record handle (a claim on the id,
not a mention of it) and a body declaring a record LIFECYCLE status, against an
id the record graph shows is taken. The graph is the same scan record_schema
trusts for its own high-water marks, so the detector cannot drift from the
stores' view of which ids are taken.
The pair is what grandfathers the undated Phase 0 notes without enumerating
them: a filename that reads like an ordinal claims nothing, a reading note that
names a decision in prose claims nothing, and a design plan headed with the
record it plans FOR carries a status of its own ("SIGNED OFF", "design
recorded") that is not a lifecycle state. An allowlist of spared files is a list
that grows silently; a fire condition is not.
The rule is structural, so it does not consult contentExempt, for the reason
record_schema does not: an id collision is not a question of how a document is
WRITTEN, and the known instance lives in exactly the tree the exemption covers.
Resolves: iss-2608230752354926
Assisted-by: Claude:claude-opus-5[1m]
The local-ephemeral tier is per-worktree and gitignored, so a checkout made with `git worktree add` starts with no private store at all: every commit from an isolated agent worktree ran with the banlist absent — warned, per design, but unprotected, which turned the isolated-worktree pattern into a systematic bypass of a protection the main checkout has. The committed guard now tells the two shapes apart the way git does (--git-dir differs from --git-common-dir only in a linked worktree), reads the primary checkout's store as a fallback layer beneath the worktree's own, and names the inherited origin in the refusal so the remedy is not invisible. Resolution never fails the commit: an unanswerable rev-parse leaves the hook exactly as it was, loud warning included. The two stores are a UNION, not a key-level override. Letting a local key replace an inherited one fails open twice — two legacy stores collide on their synthetic `entry-<line>` keys, and a local re-declaration could quietly narrow a ban the primary made — and a union satisfies the rule the intent actually states, that the fallback never overrides a worktree-local entry. The CLI read paths render the same inherited layer, so the status board and the guard cannot disagree about which entries are in force. The WRITE root stays the worktree's own: `add` never reaches into a checkout nobody named. Assisted-by: Claude:claude-opus-5[1m]
Phase 3 reached for a pre-commit config and a prepare-commit-msg hook under a maintainer-local templates directory, both "if present" — so on every machine but that one the adopt step did nothing at all and the adoption silently degraded against loud-staging. A fresh clone of this repo could not perform the onboarding this record describes. Both steps now resolve from the binary. The commit gates come from `ahoy install`, whose hooks are already embedded; the attribution prompt is a net-new embedded `defaults/prepare-commit-msg`, scaffolded by `ahoy install --attribution` and recorded in config so a later plain install keeps it and restores a hand-deleted one. The hook writes no value — which tool assisted is a fact only the committer has — and it appends its prompt only for the two message sources git strips comments from, because a prompt appended to a `-m` message goes into history verbatim. An onboarding self-containment check refuses any home-relative path in the adopt-phase record, with its own must-fail half: a gate nobody has watched fire is an assertion, not a check. classifyGuardHook becomes classifyHook(root, rel, marker) so the two hook families share one containment-respecting classifier rather than drifting apart in a second copy. Assisted-by: Claude:claude-opus-5[1m]
An autonomous run in a sibling repository posted pull-request bodies and issue comments carrying a live session URL and a tool's default attribution footer. The commit messages stayed clean, because the trailer is the one surface this repo's convention reached; the footer and the link were appended when the forge artefact was created, outside the model's own text, and the forge kept the pre-scrub revision. The same class was later found here, model-authored — an agent reasoning from a generic default. A detector cannot tell the two apart after the fact and does not need to: the shape is the finding. The class is defined once, in the scanner's canonical set, and read from there by every surface that judges text — the four store-before-commit redactors get it by being folded into DefaultPatterns; `abcd lint`'s privacy rule adds exactly these two to the network set (not the whole secret set, which is a different change with its own false-positive budget); the harness_leak rule carries it into the record and docs lint; and ScrubOutbound is the primitive an autonomous routine calls before posting. Two copies of a regex is how one of them comes to mean something weaker. Three things make it survivable in a repo that has to document its own ban. The footer's line-start requirement lives in SkipAt rather than in a `^` anchor, because the adjacency probe re-compiles a pattern body inside `\A(?:...)` over a WINDOW of the line, where `^` would match mid-line. A reserved documentation host (RFC 2606/6761) spares a worked example. And a session id must be OPAQUE — a UUID, a base62 token, or a long hex run — because a documentation slug such as .../blog/using-agent-session-management-and-1m satisfies every structural test a regex can state and sits in this repo's own research notes. ScrubOutbound takes the whole LINE for a harness-leak finding rather than masking in place: masking is right for a secret embedded in the artefact's own content, but a footer IS the line the harness added, and a masked stub still posts the shape the policy bans. It fails closed on the residual rescan. Resolves: iss-178 Assisted-by: Claude:claude-opus-5[1m]
`git push {--force,} origin main` expands in bash to byte-identical `--force`
argv, but the guard's tokenizer performs no brace expansion: it read the
literal token `{--force,}`, no blocker matched it, and a Tier-1 hazard was a
silent allow — the same mutate-the-flag-token shape the redirection branch
already closes.
The guard does not expand the group either. A correct bounded expander (the
Cartesian product of the alternatives, nested groups, `{a..z}` ranges) is
larger than this fix, and it is not what closes the bypass: a word whose argv
the guard cannot compute is a word it cannot check, so it is REFUSED. The
tokenizer records the group on the segment and Check folds it into a real
VerdictBlock, deliberately not an ErrUnparsableCommand — the `guard check`
verb maps a tokenize error to a blocking exit but the pre-tool-use hook maps
it to fail-OPEN, so the error route would have left the bypass standing on
the one surface that matters.
The three shapes bash does not expand keep their verdicts exactly, each for a
reason rather than by luck. Quoted bytes never reach a structural branch, so
`'{--force,}'` was already an inert literal. `${…}` is parameter expansion,
exempted on the raw byte before the brace — bash's own test — read off the
RAW line so a quoted dollar cannot buy the exemption. And a group needs an
alternative and lives inside one word, so `{a}`, a lone `{`, `awk {print}`
and the reserved-word group command `{ git push --force; }` all fall through
unchanged; the last still puts its inner command in command position, where
the blocker for it fires as before.
Everything else is read fail-closed: a nested group counts even though its
comma is not at the outer group's own level, and an alternative inside quotes
counts too, because a comma the scan cannot rule out is one it must assume
bash will act on.
Assisted-by: Claude:claude-opus-5[1m]
Resolves: iss-2608221457227161 Assisted-by: Claude:claude-opus-5[1m]
The Cut A §4 gate — a fresh plugin install on a machine with no Go — failed on its third and fourth assertions and blocked the release. No bootstrap line appeared at any of three session starts, the plugin root held a source checkout and no binary, and every UserPromptSubmit and PreToolUse hook errored "No such file or directory" for the whole evening. The download path was not the defect: it worked. What failed was that a degraded environment produced SILENCE, so the one thing a reader could act on was the one thing missing. The script now owes a contract it can keep. It announces provisioning before the work that can hang — a directory sweep of a plugin root full of source on a cloud-synced filesystem, then three network fetches — so a run killed in there still leaves evidence of what it was doing. And an EXIT trap closes the contract: reaching the end with the announcement made and no terminal line means the run died somewhere it does not know about, and that is reported as the same loud refusal, with the same three ways out, every other failing path gives. SIGKILL still runs no trap; nothing in a shell can cover that. The announcement goes ahead of the terminal line, which is a real trade: the transcript renders only a hook's first stderr line (iss-208), so a successful install now leads with "provisioning…" and the `ahoy install` instruction is one line further down. That is the right way round. A success the reader has to expand to see costs them a scroll; a hang with nothing on screen at all cost an evening. The announcement is placed AFTER the lock, so a session that merely lost the race stays genuinely silent — it is not provisioning, so it must not say it is. The no-bundled-binary half is made structural rather than incidental. The released `abcd-<os>-<arch>` artefacts are out of the payload today by omission — gitignored, named by no include — but omission is a config an edit can undo, and a bundled copy would be an unverified, permanently stale second source for the binary that then runs as the shell guard. The deny now sits above the include list, on the artefact's name, and REJECTS: naming one fails the ship rather than being dropped in silence. It is the whole basename, so prose about an artefact is untouched. The §4 checklist itself becomes a gate: a fresh plugin root, a release serving the real built binary, a PATH with no Go, and the manual evening's three assertions made on every build — exactly one terminal line and it is the success, a provisioned regular executable holding the verified bytes, and that binary answering with no toolchain anywhere. Two companions pin the failure side: a refusal leaves no half-installed binary and no temp directory, and a run signalled mid-provision produces the refusal rather than the empty output the old script gave. Assisted-by: Claude:claude-opus-5[1m]
…ance Resolves: iss-253 Assisted-by: Claude:claude-opus-5[1m]
The structural-tier entry linked the open-ledger path, which the galloping-probe fix moved. The plan's narrative — designed, deliberately not next — is left as the record of when that was true. Assisted-by: Claude:claude-opus-5[1m]
The galloping change dissolved two of the three things the old comment on maxAdjacencyProbeWindow described, and the rewrite dropped the third with them. It still holds: a FAILING probe attempt is deliberately never grown — that is the cost bound the constant exists for — so a pattern whose earliest required structural marker falls past the first window still matches nothing there and its token is missed entirely rather than truncated. jwt_shaped behind a long header is the case. A limit nobody wrote down is one the next reader rediscovers by being bitten. Assisted-by: Claude:claude-opus-5[1m]
The by-links arrangement published overlapping positions by construction. Its islands were placed by a spring layout with no collision pass at all, its pairs sat a fixed 0.012 apart when their own radii needed more, and its two rim rows mapped each record's arc-width onto a circle whose circumference was smaller than the sum of those widths. On this record that was 1,245 overlapping bubble pairs, and the chart never came to rest on the arrangement. The gate could not see any of it: the count lived inside coil() and measured the coil alone. Widening it is not a one-line change, because positions are published normalised to the unit disk while radii are quoted in reference pixels — the renderer reconciles the two by drawing a point at point x coil_radius, and comparing across the two spaces reports an overlap for very nearly every pair (79,800 on the 400-node fixture). countOverlaps takes the count in the renderer's space and Arrangements. overlapCount sums both arrangements into the published number. The arrangement is now built under one packing rule. Each island is sized from the area its own bubbles occupy; each pair is separated by what its two radii need; the core is drawn back to its share of the stage and compacted; and the rim winds outward from the core's own edge under the coil's forbidden-interval walk, which is now a shared clearOutward rather than a second copy. Where the stage has room left over the whole arrangement is grown to fill it, which can never bring two bubbles closer together. Measured on this record: 1,245 overlapping pairs before, 0 after, with the outermost radius at 0.962 against 0.970 — a reverted earlier attempt had left 367 overlaps and pushed that radius to 1.83. Resolves: iss-2608231350127745 Resolves: iss-2608231322321751 Assisted-by: Claude:claude-opus-5[1m]
…ning GitHub's native secret scanning and secret-scanning push protection were enabled on this repository by hand, with nothing in the tree recording the intent and nothing to reapply it anywhere else. Push protection blocks a secret at push time — earlier than any CI scan — and secret scanning covers the default branch continuously, so both belong to abcd's managed-repo posture rather than to one maintainer's memory. `abcd ahoy remote` reads the two toggles and reports what an apply would change, touching nothing. `abcd ahoy remote apply` enables them — secret scanning first, because GitHub refuses push protection on a repository whose secret scanning is off — and mirrors the desired state into .abcd/work/rulesets/repo-settings.json, whose `observed` half also records the merge-hygiene settings abcd deliberately does not drive: they had no source of truth in the tree at all, and a mirror that blurred what abcd restores with what it merely saw would read as a promise nothing enforces. Four gates stand before any change leaves the machine, and each refuses or aborts rather than guesses: the folder must be a repo abcd manages, the repository must be the one this checkout's own origin names (owner and name are charset-checked before either reaches an API path, so a crafted remote cannot redirect the request to another endpoint), the config must not set scan.native_secret_scanning to false, and the caller must CONFIRM the specific toggles named — invariant 10 asks for a verb the user invokes AND confirms, and an unanswered run declines. The call goes through `gh`, so the write is made by the caller's own authenticated identity and abcd never holds a token. The read precedes the writes, a failed step stops the sequence rather than attempting one that cannot succeed, and no partial apply is ever mirrored. Idempotent in both directions: an already-correct repository takes no remote write, and a re-run rewrites nothing in the tree. Assisted-by: Claude:claude-opus-5[1m]
Eight typed cross-references in this record point at targets absent from the tree — adr-22 supersedes adr-14, adr-15 and adr-17; adr-25 supersedes adr-8; adr-27 supersedes adr-16; adr-28 supersedes adr-18; adr-35 supersedes adr-4; itd-3 names a spec_id, spc-1, with no file — and the question the record asked was whether anything checks them. Measured: the detector fires. `abcd site check` on a tree carrying one new `supersedes: adr-999` refuses with `adr-54 → adr-999 (supersedes) is unresolved and outside the committed baseline`, and the eight known dangles are carried by `.abcd/site-baseline.json` rather than failed. But nothing pinned any of it, so every one of those behaviours was one refactor away from silently going out. The four ratchet behaviours now have tests: a new supersedes dangle fails, a spec_id naming a spec with no file fails by the other route (a graph field the record rule carries without judging, as against a handle field it parses), a baselined backlog of both kinds passes, and a baselined reference whose target is later written passes with an invitation to shrink. A fifth pins the exception the detector rests on — a supersedes naming a PRUNED record still counts, or the one field that declares an absent id would be the one field nothing could check. Each was checked against a mutant: excusing supersedes like any other field, and dropping the graph fields from the health walk, each turn a new test red. Resolves: iss-2608220150157498 Assisted-by: Claude:claude-opus-5[1m]
Resolves: iss-370 Resolves: iss-87 Resolves: iss-2608270636272755 Resolves: iss-2608270512210664 Assisted-by: Claude:claude-opus-5[1m]
Nine findings across the three new detectors. Six were defects, two were documentation that had drifted ahead of the code, and one was this diff tripping its own new gate. The outbound scrub sanitised with a silently weakened pattern set. Every other write-time redactor in the repo consults sc.Unavailable() before trusting a scan; this one did not, so a broken .abcd/config/pii.json dropped the repo's own detectors, ScrubOutbound returned the artefact byte-identical with err == nil, and the routine posted it — on the one surface whose pre-edit revision a forge keeps. It refuses now. Both lint-side detectors were weaker than the scanner they claim to share a definition with, which is the exact drift the class exists to prevent. harness_leak read only the LEFTMOST match per pattern, so a documentation URL earlier on a line disarmed the pattern for a real session URL after it — and the suppressing URL in the reproduction is the one this repo's own research notes carry. The footer's SkipAt read the whole rest of the line for a reserved documentation host, so any unrelated example.com link later on the line excused a genuine footer; it now reads the footer's OWN link target. The same SkipAt demanded column zero, which the model-authored half of the class does not satisfy — a footer written into a bullet or a quoted reply went through — so a list marker and a blockquote marker are admitted, while prose before the match stays a skip and the writable-about property is unchanged. cross_store_id_claim walked the whole tree, untracked files included. That made a blocker out of the gitignored local tier AGENTS.md tells agents to use for scratch output, and out of a git worktree created inside the checkout, which the concurrent-sessions guidance contemplates. It reads the TRACKED set now, the same scope the privacy rule uses; an untracked file is not yet a claim on an id. Its decision-shape test also matched the frontmatter `status:` key it documented itself as ignoring, so a verbatim copy of any ADR fired with no Status section in it at all — the scan starts at the body. agent_contract's changelog sub-check was diff-only and nothing passes a range, so it never ran outside a test while agents/README.md said the contract was enforced. The entry requirement is tree-shaped now and holds on every invocation; the armed range keeps the one thing a tree cannot say, an edit with no prompt_version bump, and CI arms it when the base commit is present. The canary check was a bare Stat that an empty file satisfied. And the capability scope parser flipped out of the block on a YAML block-sequence item, so a contract-complete prompt drew a finding telling its author to add a field that was already there. Finally, this diff's own scanner fixtures made `abcd lint` fail its new privacy rule. They cannot move to a documentation host — that would suppress the very detection each case asserts — so they carry the rule's own abcd-lint:allow waiver, which is what that escape is for. Two residual coverage boundaries are captured rather than guessed at: iss-2608281948217899 (which session-URL spellings the covered harnesses mint) and iss-2608281948289198 (a prompt filed one directory down). Assisted-by: Claude:claude-opus-5[1m]
The roadmap promises that Pass B — the pass that mines chat transcripts for the rationale nobody wrote down — "ships as a declared exemption in _provenance.json, never a silent gap" (phase-6-lifeboat.md). itd-88's fidelity audit found the promise had no implementing code: no exemption field existed anywhere in the lifeboat package, so a section Pass B would have grounded arrived as a blank indistinguishable from one nothing could ever ground, and a reader was left to answer a question no pass had been run against. Provenance gains a pass_b_exemption carrying its reason — a typed marker rather than a bare bool, because what was promised is a DECLARED exemption and a reader of the artefact has to be able to see why the pass is absent. It is derived from the tiers the pack actually drew on, not asserted: Tiers() is git, conventions and abcd-native and none of them is a transcript store, so every lifeboat this build packs is exempt, and the declaration stops being written when a pack draws on a tier outside that set. The embark coverage handoff carries the declaration through from the provenance and the blanks-first report names it before the blanks, so the pass that did not run is stated where the work a human is being handed is stated. The field is an omitempty pointer: a lifeboat with no marker — one packed before the field existed, or one a transcript pass did run for — round-trips byte-identically and reports exactly as it did, which a test asserts by diffing the two renders line for line. Resolves: iss-136 Assisted-by: Claude:claude-opus-5[1m]
Three findings from re-reading the diff against the shapes it does not test. The linked-worktree fallback trusted the common dir's PARENT to be a working tree. For a bare repository — or one made with `--separate-git-dir` — it is not: it is whatever directory happens to hold the git dir, so a worktree of a bare clone would read `<that directory>/.abcd/.work.local/private-names.txt` and enforce the private list of an unrelated repository living next door. The primary root must now carry a `.git` entry, which a real working tree always does. The attribution prompt assumed git strips `#` lines. Under `commit.cleanup=verbatim`, `whitespace` or `scissors` it strips nothing, and under a repo-chosen `core.commentChar` the `#` prefix is not a comment at all — so the block went into the commit message verbatim, permanently. Both settings are now read: the character is substituted, and a mode that cannot strip skips the prompt with a loud reason rather than corrupting history. `auto` is refused because it cannot be predicted from outside the message. The guard's scratch-directory comment claimed a present store implies a local tier. In a linked worktree the store can be inherited while the tier is absent, and the temp-directory branch is then the right one for a second reason: a copy of the staged tree must not be written into another checkout. Assisted-by: Claude:claude-opus-5[1m]
…empt The doc comment on renderCoverageBlanks still described the shape it had before the exemption line: it said an absent coverage prints nothing, and an absent coverage that declares Pass B exempt now prints the declaration. Assisted-by: Claude:claude-opus-5[1m]
Two blockers from each pass, plus their notes. The Go resolver did not apply the working-tree test its shell counterpart had just gained, so for a worktree of a BARE clone the board resolved, read and rendered the private store of an unrelated repository next door — and printed "these entries are enforced here too" about entries the guard, which does apply the test, was not enforcing. A status surface announcing protection that is not running is the one failure this layer forbids. Both halves now require a `.git` entry, and a bare-clone worktree is pinned in both suites. `--attribution` was gated on the safe-autocreate CATEGORY's approval, which is only granted when some other resolvable gap is open. On an already-installed repo — the one the adopt phase runs `install --attribution` in — the flag wrote nothing and reported nothing: the silent degradation itd-162 exists to remove, reintroduced one flag along. An explicit flag is now its own approval. The remote verb resolved WHICH repository from git's upward search and WHETHER it may from a cwd-rooted config read, so from a subdirectory it read an absent config as consent and PATCHed a repository whose maintainer had opted out, then wrote the mirror into a stray `.abcd` tree while reporting the committed path. Every question is now anchored at the working-tree root, and a config that does not answer is a refusal rather than consent. The API host is pinned explicitly: `gh` otherwise takes it from GH_HOST, so the github.com-only rule constrained the path while the environment chose the endpoint. The zero-entry banner was keyed on the SUM across both stores, so an emptied worktree store went silent whenever the inherited one had an entry — the exact truncation the banner exists to catch. It is per store now. The guard printed the primary checkout's absolute path on the success path of every commit. A checkout's directory name is very often the private name its own store bans, so that echoed the banned string to stderr, scrollback and any log capturing hook output — on a commit that passed. Output names the store relative to the other checkout instead; the CLI render redacts $HOME and now says the fallback is read-side only, since `add --private` still writes locally. The attribution prompt read only `core.commentChar`, missing the `core.commentString` that supersedes it in git 2.45+, so a repo setting the newer key had the whole block written into every commit message verbatim. An unreadable store fell to `set -e` as a bare "Permission denied"; it now refuses loudly, the standard this file already holds a missing tool to. A non-interactive `remote apply` that declines exits non-zero, so a change that did not happen cannot look to a script like one that did. Assisted-by: Claude:claude-opus-5[1m]
pass_b_exemption.reason comes verbatim from _provenance.json, which manifest verification deliberately excludes, so it is free text in any lifeboat anyone can hand a user. The human render masked it, but the handoff is also what --json emits: an adversarial review measured raw U+009B, U+202E, U+200B and DEL reaching the JSON payload, where a consumer piping it onward never sees the render's own masking. Every other string in CoverageHandoff is sanitised where the handoff is BUILT — Question, Searched — and this one now is too, which is also where the convention says to look for it. The render keeps its own sanitise; masking is idempotent, and the rule there is that every lifeboat-derived string passes through it. The measured leak on source_name and record_manifest_sha256 is the same pre-existing class (iss-359) and is deliberately left alone: it is one change over all of them, not a fix for whichever field was touched last. So is the unbounded length of the reason, which cannot forge a line — sanitize masks the newline — and which source_name shares today. Also corrects what passBExemption's comment claims: the code writes no declaration when the pack drew on any tier outside Tiers(), not specifically a transcript tier. Assisted-by: Claude:claude-opus-5[1m]
**The brace refusal had a nine-character bypass of itself.** `braceExpansionAt`
treated `(`, `)` and a backtick as word terminators, but none of them ends a
bash word when it opens a substitution — bash brace-expands straight through
one, so `git push {--force,$(true)} origin main` hands the child byte-identical
`--force` argv while the scan read "no group here" and the guard allowed it.
The same for the backtick spelling and for `<(…)`. The scan now skips a
substitution for STRUCTURE, counting an alternative inside it rather than
reasoning about what it would produce, and bare parens stay terminators.
**And the `$` exemption was one byte too wide.** `\${a,b}` is a literal dollar
followed by a live brace group — bash expands it to `$a $b` — so the exemption
now requires a `$` that is not itself escaped.
**The forward scan was quadratic.** It looks ahead from every structural `{`,
so a word of nothing but `{` re-reads the same tail once per byte: a megabyte
of them, well inside the guard's own stdin cap, took over six minutes. That is
a hang on the PreToolUse path reachable by any command an agent can be asked to
run. A budget shared across one tokenize call bounds the total look-ahead, and
exhausting it refuses rather than allows.
**The scanner's galloping probe was quadratic too, in the way the round-6 cost
regression was.** Removing the fixed cap also removed the cap on how long a
probe-recovered match may be, and every recovered match is re-validated up to
maxAdjacencyBacktrack times at O(match length). A line where Theta(n) junctions
each start a Theta(n)-long match therefore squared: 1.3s / 5.0s / 19.1s over
14KB / 29KB / 59KB, where the fixed window had been 0.6s / 1.3s / 2.5s. One
growth budget per line, a multiple of the line's own length, keeps a single long
token whole — the shape the gallop exists for — while a line engineered to grow
the window at every junction exhausts it and reverts to the bounded fixed
window. The same measurements are now 0.66s / 1.32s / 2.73s: linear again, and
back on the pre-change curve. The shape is pinned as a cost case, and a
cost-CLASS test asserts the growth ratio rather than a wall-clock ceiling — a
ceiling could not have caught this, since both curves sit far under any fixed
bar at the sizes the other guards use.
**The bootstrap announcement was taking the only line a reader gets.** Only the
first line of a hook's stderr reaches the transcript (iss-208, measured), and
the success notice already spends it on the one-time `ahoy install` instruction
placed there for exactly that reason (iss-207). An announcement ahead of it took
that line from the success and — far worse — from the refusal's cause, which is
the silence itd-154 exists to end. The announcement is dropped and the two
assertions that would have caught it are restored to `firstLine`; the EXIT trap,
which is the half that actually closes the silence, now names provisioning in
the one line it emits. The literal "provisioning…" line the spec asks for is
deliberately not shipped: it cannot be had without paying for it with the line
that matters more.
Two smaller holes close with them. The reserved brace id joins
`reservedEntryIDs`, so a repo registry cannot dress an ordinary entry up as the
guard's own voice — the invariant its own comment already stated. And the
payload deny covers the bare `abcd` that `go build ./cmd/abcd` produces, which
is the name the binary runs under in the plugin root and so the likeliest of the
three to be committed by accident; the committed-tree half of its test asserts
against `git ls-files` instead of only logging.
Assisted-by: Claude:claude-opus-5[1m]
iss-28's gate: a test that spawns git must do it with gittest.Env, so it cannot read the developer's own git configuration and pass or fail on it. The committed -tree half of the platform-binary deny asks git which files are tracked; it now asks the same way every other test does. Assisted-by: Claude:claude-opus-5[1m]
An adversarial security review reopened this branch's own bypass four
characters wider, and in a worse form. `braceExpansionAt` treated the first `}`
that returned depth to zero as the group's end, so a group whose first
alternative carries a literal `}` was read as inert text — and bash does not
read it that way. Checked against bash 5.3: `git commit -m {msg},--no-verify}`
expands to argv `git commit -m msg} --no-verify`, which RUNS: `-m` swallows the
junk alternative as the commit message and `--no-verify` skips the hooks. The
byte-identical spelling blocks on `git-commit-no-verify`; this one was a silent
allow, as were `git push {x},--force} origin main` and
`cd /tmp/x && rm {y},-rf} *`.
A `}` now ends the scan only once a separator has been seen at that level;
without one it is an ordinary byte inside a group that is still open, which is
how bash reads it. A differential check against real bash over the brace,
quote, `$` and backslash shapes finds no remaining word bash expands into
different argv that the guard allows. It does not over-block: `{a}`, `{a}b`,
`{a},x`, `{a} b,c}`, `{}` and `awk {print}` are single words to bash and stay
allowed, while `{a}{b,c}` and `{a},b}` — which bash does expand — are refused.
Two comments that the same review found falsified are corrected with it. The
scanner's maxAdjacencyBacktrack claimed a junction further back than the window
"would need a crafted multi-kilobyte token, which no bundled pattern can
produce"; recovering an open-ended token at its TRUE length made a few-hundred
-byte `sk-proj-` key enough, so the note now says what the consequence actually
is — a lost label, not a lost redaction, since the bytes stay inside the
enclosing hard-fail span. And the payload deny is described where it sits: after
the include match, so an artefact nobody asked for stays a default-deny miss
rather than a reported violation.
Assisted-by: Claude:claude-opus-5[1m]
Neither is a defect in what ships; both are the part of an acceptance criterion this round decided not to buy, and a decision nobody wrote down is one the next reader has to rediscover. Assisted-by: Claude:claude-opus-5[1m]
The full-history secret scan checks out with fetch-depth 0 and scans every remote ref, so a secret literal on a superseded branch fails the gate on pull requests that never touched it. Assisted-by: Claude:claude-opus-5[1m]
… what Four findings from an adversarial review of this branch. The exemption's derivation was a constant wearing a derivation's clothes, and its guard was inverted against the way a tier is actually added. TiersPresent is produced only by tiersPresent(), which appends exactly the members of Tiers(), so `if !settled[t]` could never fire — and registering a transcript adapter means adding its tier to BOTH Tiers() and tiersPresent(), which would have put it in `settled` too. The declaration would then still be written for a pack Pass B did contribute to: a false claim in a durable artefact, which is worse than the silent gap the field was added to close. The set of tiers that carry transcripts is now named, empty, and checked positively, and it is a parameter so the branch that STOPS the declaration is exercised before any adapter exists. The final settle over every node is a no-op on the pipeline that seeds it — the core pass and the rim's own walk leave nothing to clear — so removing it left the package green, and a pass whose whole job is to guarantee an invariant had no test standing behind it. It keeps the invariant when an earlier stage misbehaves (removing the core pass instead shows it catching one), so it stays, and settle is now tested directly against a seed of piled-up bubbles that no arrangement produces. Two test defects: the pruned-record assertion assigned where it had to accumulate, so a second unresolved reference from the same record would have erased the finding; and stripPassBExemption's comment promised a return value it does not have. Assisted-by: Claude:claude-opus-5[1m]
`scanner.ScrubOutbound` has no caller: nothing under `internal/surface`, `commands/` or the plugin surface reaches it, and `internal/` cannot be imported from outside the module, so it cannot acquire one from elsewhere. Three surfaces are genuinely wired: the canonical pattern set the store-before-commit redactors read, `abcd lint`'s privacy rule, and the `harness_leak` record/docs-lint rule. Claiming the fourth is worse than lacking it, because all three wired surfaces judge text that is already committed or already stored and none of them reaches a forge artefact, which is the vector iss-178 reports. The loud-staging principle says a stage that no-ops must say so, so AGENTS.md, the iss-178 resolution and spc-45 name the primitive as a primitive: real and tested, deliberately without a forge client per spc-45's own scope decision, with the re-read-and-strip protocol carrying the posting-time protection until it is wired. Assisted-by: Claude:claude-opus-5[1m]
The primitive is real and tested; what it lacks is a front door. Records the two candidate shapes a future change can take, so the gap the prose stopped claiming away has a ledger entry to find it by. Assisted-by: Claude:claude-opus-5[1m]
The harness-leak tests embedded a session-identifier-shaped literal as a source constant, in the very change that adds the detector for that class. This repository scans FULL history (gitleaks git, fetch-depth 0) and main cannot be force-pushed, so such a literal is unremovable once merged — the trap iss-2608282038283692 documents from the other direction — and iss-178 had said the remedy would carry leak shape only, with no session ids reproduced. The literal also forced three escapes to get itself past this class's OWN detector: a line-scoped lint waiver on each fixture, a split literal (strings.Join on the id and on the footer) so the source line no longer matched, and a reserved-documentation host standing in for a specimen. All three are gone. Every session-identifier-shaped fixture is now built at runtime from internal/testsecret, seeded per case, which is the principle the secret fixtures already keep (secret-shaped-fixtures-at-runtime). The scanner's helpers assert the generated id is opaque by the detector's own test, so a fixture that silently stopped exercising the pattern fails loudly rather than passing a weakened assertion — verified by mutation: substituting a non-opaque id fails all six session-URL tests. testsecret gains SyntheticHex for the lower-case-hex and UUID spellings; Synthetic's output is unchanged (both now share one walk). `abcd lint` reports 0 harness-leak findings over the tree with no waiver left to hide one, and `gitleaks git` over this branch's history is clean. Assisted-by: Claude:claude-opus-5[1m]
checkAgentChangelog reached the filesystem with a bare os.ReadFile while every sibling read in the same function was already guarded — agents_dir by containedRepoPath, each prompt by containedRealPath plus fsutil.ReadGuarded, whose refusal says "the lint reads only inside the repository". The changelog was the one that was neither contained nor capped, and both its path and the file it names are repo-controlled: the path comes out of the in-tree lint config and the file out of the tree, so a fork pull request supplies them and CI's `go run ./cmd/record-lint` is what reads them. Two shapes, both reproduced as failing tests first: - agents/CHANGELOG.md committed as a symlink to /dev/zero. The read never returned — the test hit its 20s ceiling with the lint still allocating, which on a CI runner is memory exhaustion. - `"changelog": "../../../../etc/hosts"`. The read left the repository, and the traversed path was echoed back in the finding's File field. Guarded with the primitives already in this function rather than a third mechanism: containedRepoPath refuses the configured path, containedRealPath refuses a symlink out of the tree, and fsutil.ReadGuarded (O_NOFOLLOW, non-regular refused, size capped at maxAgentPromptBytes) refuses the device. An absent changelog stays a state, not a fault. A third test pins the other direction — an in-repo symlinked changelog is still followed, as containedRealPath already does for a prompt — so the guard cannot later be tightened into a refusal of ordinary in-tree layout. Assisted-by: Claude:claude-opus-5[1m]
… arithmetic The linked-worktree fallback located the primary checkout as the common dir's parent and accepted it if that directory carried a `.git` entry. The parent is not the working tree wherever the git dir sits outside it — a bare repository, or one made with `--separate-git-dir` — and the `.git` test does not close the gap: clone bare into a directory that lives inside somebody else's checkout (or point `--separate-git-dir` there) and the parent IS a real working tree with a real `.git`. Both halves then read that stranger's `.abcd/.work.local/private-names.txt` and the committed guard enforced their private list on this repository's commits: a match/no-match oracle over their patterns, their keys printed into this repo's hook output and agent transcripts, and every commit here refused by one malformed line over there. Both halves now ask git which working tree is the main one — the first record of `git worktree list --porcelain` — and then require that candidate to CONFIRM the relationship from its own side: its `--show-toplevel` must be the candidate itself (no directory that merely holds a git dir can answer that; git says "must be run in a work tree" for a bare repo and a `--separate-git-dir` git dir alike), and its `--git-common-dir` must be ours. The second test is what makes the answer unspoofable by layout: a neighbouring checkout discovers its own `.git`, never the mirror planted inside it, so the two common dirs differ and the candidate is refused. In the shell half the probe runs with git's hook environment unset inside the subshell, because an inherited GIT_DIR overrides `-C` and would make the confirmation answer with our own repository whatever directory it was handed — a check that passes vacuously is worse than no check. Resolution still never fails a commit: every failure leaves the guard single-store, exactly as in a standalone checkout. Assisted-by: Claude:claude-opus-5[1m]
checkDeliveryState carried the identical unguarded read to the one just fixed in checkAgentChangelog — a bare os.ReadFile on a path out of the in-tree lint config, naming a file out of the tree. Both rules are armed as blockers in this repository's own .abcd/record-lint.json, so the same `go run ./cmd/record-lint` CI step reads both, and guarding one of the two leaves the step exposed either way. Reproduced first, both failing before the change: - CHANGELOG.md committed as a symlink to /dev/zero. The read never returned; the test hit its 20s ceiling with the lint still allocating. - A configured changelog resolving outside the checkout. The rule's existing fail-closed behaviour hid this, because it only refuses a path it CANNOT read — point it at a file that is there and the gate happily judged a document outside the repository. Same primitives as the sibling: containedRepoPath, containedRealPath, fsutil.ReadGuarded. The cap is its own constant because a root changelog is a repository's largest prose file and grows with every release; 4 MiB sits far above any real one (this repo's is 260 KB) while still bounding a hostile tree. Assisted-by: Claude:claude-opus-5[1m]
`abcd banlist list --private` in a linked worktree emitted `"primary_root": "/Users/<name>/…/<checkout>"` straight through the JSON encoder, and the text render applied RedactHome — which rewrites a `$HOME` prefix and leaves the checkout's DIRECTORY NAME standing. That name is very often the private name the store itself bans (a project codename is the commonest entry there is), and this layer's whole contract is that no pattern value reaches output. The render runs on the success path of an ordinary status read, so it lands in scrollback, agent transcripts, CI logs and any file the caller redirected `--json` to; this repo's own privacy-hygiene rule classes `/Users/<name>` as a SeverityError in committed content. The committed shell guard already refuses to print it and says why. Give the Go half the same restraint, in the stronger form: InheritedReport no longer carries the path at all, so no front door can print what it does not hold. The remedy a reader needs is "the primary checkout's <store path>", and the store path is repo-relative — it names the location without naming the checkout. Pinned by the Go twin of the shell guard's own TestPreCommitHook_NeverPrintsThePrimaryCheckoutsPath, over both renders and both verbs, with the primary checkout deliberately NAMED for the string its store bans. Assisted-by: Claude:claude-opus-5[1m]
REPPL
enabled auto-merge
August 28, 2026 21:58
TestGallopingProbeStaysBoundedOnLongLines failed on both CI runners at 22.5s against its 15s bar while passing locally at 11.0s: the bar is wall clock, and the race detector's instrumentation costs more on the runners than on a development machine. The shape is not a regression. Measured on one machine, the branch that introduced the probe and the merged tree take the same time, and the cost-CLASS guard beside it still passes. What failed is a ceiling with 25% headroom on the machine that happened to run it. Shrink the input rather than loosen the budget, which is the lever scaleAdversarial already documents: this case costs an order of magnitude more per unit than its siblings, so it carries a smaller multiplier. It still discriminates, because its growth here is linear in the multiplier (3.5s, 6.4s, 12.6s at 500, 1000, 2000 under -race), so a return to quadratic still leaves the bar at once. Assisted-by: Claude:claude-opus-5[1m]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the twelve planned intents itd-150..158 and itd-160..162 against their
specs spc-43..54. itd-159 stays held:
abcd intent ready itd-159refuses it, andthe gate was run for all thirteen before any implementation began.
Built by four agents in parallel, each in its own worktree on file-disjoint
territory, each working test-first against its spec's Approach section and each
running a ruthless and a security adversarial pass over its own diff. The four
branches were then merged here and put through a second pair of adversarial
passes over the combined diff, which is where the two blockers below were caught.
What lands
agent_contractbrings theagents/tree under record-lint and enforces the itd-5 trust contract.abcd ahoy remote applyenables GitHub's native secret scanning and push protection, confirmed and idempotent.cross_store_id_claimsees a decision-shaped document filed outside the record stores.Fourteen issues resolved, each with a
Resolves:trailer and a ledger move in thesame diff.
What the integration review caught
Two findings blocked the merge until fixed. Both were branch-local, and both
survived their own branch's security pass.
A worktree of one repository read and enforced another's private name store.
The "is this a real working tree" test was a bare
.gitexistence check, which abare mirror placed inside another checkout satisfies. The guard then read the
neighbour's store, enforced its patterns, and named its keys in the refusal: a
match/no-match oracle over exactly the data whose contract is that its values
never reach output, plus cross-repo denial of service from one malformed line.
Primary-worktree resolution now asks git and requires the candidate to confirm the
relationship from its own side. The shell half additionally unsets
GIT_DIRandits siblings around the probe, because git exports
GIT_DIRinto a hook andGIT_DIRoverrides-C, which made the check pass vacuously.The new harness-leak fixtures were a live session identifier, committed as a
literal, in the change that adds the detector for that class. It was carried
past the detector by three separate escapes in the same commit: a waiver, a
string split so the source line no longer matched, and a reserved-host skip.
This repository scans full history and
maincannot be force-pushed, so it wouldhave been unremovable. Every such fixture is now generated at runtime and asserted
opaque by the detector's own predicate, and all seven waivers are gone.
Three further findings were fixed rather than deferred: an unguarded
os.ReadFilethat a fork pull request could turn into CI memory exhaustion, present in two
rules that the same CI step reads; the new render printing the primary checkout's
absolute path, which the shell half of the same change already forbids and pins
with a test; and a claimed fourth mitigation surface that no code reaches.
Deliberate divergences from the specs
Each was taken in favour of the intent's acceptance criteria, and each is stated
here rather than left to be discovered.
union shipped instead, because an override fails open twice. The review
reproduced the collision independently.
contentExempt; it does not, becausethat exemption covers the corpus which motivated the rule.
both reviewers flagged it as a regression because it consumes the one line the
transcript renders and displaces a refusal's cause, and it was reverted.
Captured with the design that would satisfy both.
scanner.ScrubOutboundships as a tested primitive with no front door, becausespc-45 scopes a forge client out.
AGENTS.mdand the ledger say three wiredsurfaces and name the gap.
Still open
The intents remain in
planned/and the specs inopen/. Promoting them wants anindependent audit of delivered code against each acceptance criterion, which is a
separate step and a maintainer decision.
Seven findings were captured rather than guessed at, including one worth
flagging on its own: the guard now refuses every unquoted brace group, so an
everyday
mkdir -p foo/{a,b}is blocked. That is the intent's chosen posture,and it is cheaper to revisit now than later.
Assisted-by: Claude:claude-opus-5[1m]