Skip to content

fix: seven priority issues, with the detectors the reviews demanded - #504

Merged
REPPL merged 7 commits into
mainfrom
fix/priority-sweep
Aug 25, 2026
Merged

fix: seven priority issues, with the detectors the reviews demanded#504
REPPL merged 7 commits into
mainfrom
fix/priority-sweep

Conversation

@REPPL

@REPPL REPPL commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Seven priority issues, each with a detector proven both ways, and two adversarial reviews that between them returned BLOCK and FIX FIRST.

The reviews were the valuable part. They found six defects in the fixes, and the worst of them was mine.

What landed

commit issue what
fix: iss-203 privacy-hygiene reports an unusable pii.json instead of scanning with silently weakened severities
fix: iss-287 one null predicate, the YAML 1.2 core set; lint's private duplicate collapsed into it
fix: iss-285 bare vs quoted YAML nulls part the way record-lint parts them
test: iss-2608242043243131 the preflight gate list is derived from the recipe, not restated by hand
fix: iss-2608241115201044 a session-start notice is no longer signalled as a hook failure
docs: SD001 is reserved, not armed, and the tree does not satisfy it
record: iss-2608251011427187 the shell half of the same premise, captured not bundled

The security review returned BLOCK, and it was right

I opened a prompt-injection channel while fixing a delivery bug. My first cut routed session-start notice text to stdout, which the harness injects into the model's context. The notices interpolate meta.setup_version from .abcd/config.json — a tracked file a pull request or fork can set. The reviewer planted a directive payload and watched it arrive in context verbatim.

Its framing is the lesson: "Sanitising for a terminal is not the same as sanitising for a context window, and this line changed which of those two the string is destined for." termsafe.Sanitize masks control bytes and leaves prose alone — prose is the whole of an injection.

hooks/bootstrap.sh's own comment already said so: "a SessionStart hook's stdout becomes model context". The hazard was documented in the file beside the one I was editing.

Now: text to stderr, a constant to stdout, exit 0. The test asserts the negative — that repo-derived text does not reach stdout.

Second blocker: my iss-203 fix claimed more than it delivered. Blanking a custom detector's regex still dropped it silently — no Unavailable(), no finding, abcd lint reporting conforms at exit 0 over a file it had been catching. One character from the case I did catch. Closed in mergeConfig, bounded to new pattern names so a bundled-severity raise is unaffected — and the negative case caught an over-broad first attempt.

The ruthless review found the detectors were weaker than claimed

  • The gate-list detector was defeatable by exactly the drift it targets: a skip-when-absent sentinel meant rewriting a sentence to name two of five gates removed the sentinel with them, and the subtest passed by skipping.
  • Its Makefile subtest could not fail — it read the whole file, including the recipe line the expected set is derived from.
  • The empty-regex change had no detector in either direction.
  • The stdout constant named a verb that doesn't carry the detail (abcd ahoy renders a gap count, not staged transcripts) — so the one notice naming a privacy artefact was the one it lost.
  • parseFrontmatterBlockQuoted's second return was always nil, discarded by both callers, with a doc comment justifying it that was false.

All fixed; every defeat is proven to fail now.

Three of my tests passed for the wrong reason

Worth naming, because it is the recurring defect of the sweep:

  • {"bare empty", "impact:", true} passed because got, _ := v.(string) swallows a non-string and yields "". Asserting the type first exposed a real split still open: impact: with no value parsed to a map, which capture rejects and record-lint reads as null. Fixed with a lookahead.
  • The privacy fixtures were written as a JSON array when patterns is a map, so they exercised "invalid JSON" rather than the empty-regex path they named.

Deliberately not done

  • iss-2608241612087533 (release derivation counting back-filled resolutions): needs emit.go to test resolved_by.commit reachability, but six swept records carry no stamp, so the check changes what a release contains. Its own change, its own review.
  • The shell half of the SessionStart premise: bootstrap.sh exits 2 on success for the same disproven reason, but its exit code also governs the install path. Captured as iss-2608251011427187, and the test documents the scope boundary rather than hiding it.

Gates

make preflight exit 0, gofmt -l . clean, record-lint and docs-lint 0 blockers, make lint-issuesRS001 checked iss-2608242043243131 iss-2608241115201044 iss-285 iss-287 iss-203 — OK.

Assisted-by: Claude:claude-opus-5[1m]

REPPL added 7 commits August 25, 2026 11:16
hooks/bootstrap.sh's notice() and hooks.json's SessionStart chain still exit
non-zero to surface a message, which is the mechanism iss-2608241115201044
found broken for the binary. Deferred rather than bundled: bootstrap.sh's exit
code also governs the install path.

Its own comment carries the reasoning that makes the binary fix correct — a
SessionStart hook's stdout becomes model context — which an adversarial review
then demonstrated concretely.

Assisted-by: Claude:claude-opus-5[1m]
privacy-hygiene guarded on `if sc, err := scanner.New(...); err == nil`,
expecting a fallback to the built-in patterns on error. scanner.New never
returns a non-nil error: an unreadable, unparseable, symlinked or uncompilable
.abcd/config/pii.json each yield a usable scanner marked unavailable. So the
guard was always true, the fallback it appeared to protect was dead code, and a
broken override silently dropped the repo's RAISED severities while the scan
reported "conforms" — the didn't-scan-reported-clean shape this rule's own
contract forbids, invisible to the one person who had raised a severity.

The rule now consults sc.Unavailable() and emits an error Finding naming the
config and the reason.

An adversarial review then showed the contract was still bypassable one
character away: BLANKING a new pattern's regex dropped the detector with no
Unavailable() and no finding, and `abcd lint` reported conforms at exit 0 over a
file that detector had been catching. mergeConfig now refuses an empty regex,
bounded to NEW pattern names — bundled names never reach that loop, so raising a
built-in pattern's severity, which legitimately carries no regex, is unaffected.
Both directions are pinned, and the negative case is what caught an over-broad
first attempt.

The fixtures are worth a note: written first as a JSON array, they parsed as
invalid JSON and passed for that reason rather than exercising the empty-regex
path at all. `patterns` is a map. A test that passes for the wrong reason is the
recurring defect of this whole sweep.

Resolves: iss-203

Assisted-by: Claude:claude-opus-5[1m]
`IsNull` recognised `""`, `null` and `~` only, so `impact: NULL` — which every
YAML parser reads as null — was a value. internal/core/lint carried a private
copy of the same three, which is where it bit: capture read one spelling as null
and the lint read it as a malformed impact, so a record could pass record-lint
and then fail the command that acts on it.

The set is now the YAML 1.2 core schema's exactly — `""`, `~`, `null`, `Null`,
`NULL` — and deliberately NOT a case-insensitive compare. `EqualFold` would
accept `nUlL`, which no YAML parser does, and abcd would then read records
nobody else agrees with: a wrong answer that only surfaces once the file leaves
this toolchain, which is harder to notice than the miss it replaces. The test
asserts both halves.

The lint's copy delegates rather than restating the set. An adversarial review
enumerated all 18 call sites — spec ids, kinds, statuses, slugs, superseded_by,
impact — and confirmed none can legally hold `NULL`, and that
`frontmatter.Fields` returns the raw trimmed scalar with no unquoting, so the
raw-scalar contract the doc now states actually holds at every caller but one:
lifeboat's graveyard unquotes first, which is recorded rather than changed here.

Resolves: iss-287

Assisted-by: Claude:claude-opus-5[1m]
Quoting is what separates a null from a string in YAML: bare `null` is a null,
`"null"` is the four-character string. record-lint tests the RAW scalar and gets
that right. capture parsed first, and parsing unquotes, so it got it wrong — and
widening the null set for iss-287 made the split WIDER, not narrower, which is
why these land together.

A bare null spelling now normalises to `""` at parse time while a quoted one
stays the string it spells, so every downstream nullness test runs on a value
that already knows which it was. That avoids threading quotedness through
validate and its callers.

An adversarial review found the empty spelling still split, and the test that
should have caught it passing for the wrong reason: `impact:` with no value took
the nested-object branch and parsed to a `map[string]any`, which capture rejects
as "must be a string" while record-lint reads the same raw scalar as null. The
test's `got, _ := v.(string)` swallowed the non-string and yielded `""`, so it
would have passed for any type. It now asserts the type first, and a lookahead
parts the two cases: an object has an indented member, a bare `key:` does not.

Also removed: a `quoted` map returned by the parser, populated nowhere and
discarded by both callers, with a doc comment describing plumbing that did not
exist. Dead scaffolding against "wired or it isn't done", and the justification
for keeping it was false as well.

Resolves: iss-285

Assisted-by: Claude:claude-opus-5[1m]
`abcd hook session-start` returned exit 2 whenever it had notices, on the
documented belief that a non-zero SessionStart puts a hook's stderr in front of
the human without blocking. The harness instead renders an opaque
"SessionStart:startup hook error" banner followed by a truncated echo of the
hooks.json command string, and DROPS the stderr text. Every notice the hook
exists to deliver — transcript-capture gaps, staged-drain failures, the backlog
count, binary skew — arrived as an error with no content, which is worse than
silence: it reports a fault in abcd rather than the condition it was reporting.

The exit is now 0. The notice TEXT goes to stderr. Only a CONSTANT and a count
go to stdout.

That split is the load-bearing part, and a first attempt got it wrong by putting
the text on stdout. SessionStart's stdout is injected into the session's context,
and the notices interpolate repo-derived strings: an adversarial review planted a
directive payload in `meta.setup_version` in `.abcd/config.json` — a TRACKED file
a pull request or fork can set — and watched it reach context verbatim.
termsafe.Sanitize defends a terminal, not a context window; it masks control
bytes and leaves prose untouched, and prose is the whole of an injection.
hooks/bootstrap.sh's own comment had already recorded the reasoning ("a
SessionStart hook's stdout becomes model context"), which I had not read before
routing notices there.

The constant names `abcd history staged` and `abcd ahoy` rather than `ahoy`
alone: ahoy renders install state and a gap COUNT and says nothing about a
staged transcript, so the one notice naming a privacy artefact was the one it
lost.

Five tests moved off the abandoned contract, and the stub binary with them — it
hard-coded `exit 2`, so the fixture modelled a contract the binary no longer
has. One assertion deliberately still expects exit 2: that is the BOOTSTRAP's
code, and the shell half of the same premise is out of scope here because
bootstrap.sh's exit code also governs the install path. Tracked as
iss-2608251011427187.

Resolves: iss-2608241115201044

Assisted-by: Claude:claude-opus-5[1m]
`preflight`'s prerequisite list is a derived value that three prose surfaces
restate by hand, and nothing derived them from the recipe. It drifted twice in
two releases, each time caught only by a host-run semantic reviewer refusing a
release: `lint-issues` made it four gates and one file was updated, then
`site-render` made it five and the same three files still said four.

The test reads the prerequisites off the `preflight:` recipe and asserts every
surface that enumerates them names the same set. It is the move
TestInstallGuideDocumentsTheInstallAndUpdatePath already makes for the
marketplace slug, applied to the gate list.

It immediately caught two live instances in AGENTS.md, which said three gates in
one passage and four in another — contradicting the recipe and itself. Both are
corrected here; they were the detector's founding corpus.

An adversarial review then found two holes in the detector itself:

- A skip-when-absent sentinel made it defeatable by exactly the drift it
  targets. Rewriting a sentence to name two of five gates removes the sentinel
  along with the gates, and the subtest passed by skipping. The file list is
  hand-curated, so a surface that stops enumerating leaves the list in the same
  change instead.
- The Makefile subtest read the whole file, including the recipe line the
  expected set is derived from, so containment held against its own input and it
  could not fail. It now reads only the comment block above the recipe.

Both defeats are proven to fail now, and adding a sixth prerequisite is proven
to fail all four enumerating surfaces.

Resolves: iss-2608242043243131

Assisted-by: Claude:claude-opus-5[1m]
The verification matrix's surface-discipline row claimed "lint `SD001` blocks on
forbidden sub-verb introduction". No SD001 rule exists: the naming chapter calls
it a reserved code, `abcd lint` emits no such finding, and the only occurrences
in the Go tree are two explanatory comments. The adjacent vocabulary row IS
caveated as reserved-but-unimplemented; this one was not, so it read as a live
gate — the phantom-gate shape enforcement-claims-are-facts exists to prevent,
where a reader believing a check exists stops compensating for its absence.

The row now carries the same caveat, and says what the previous wording hid:
the shipped tree does not currently satisfy the criterion either. `abcd history
show` and a plain `abcd history list` both ship, `abcd banlist list` renders
byte-identically to bare `abcd banlist` — the redundancy the rule exists to
forbid — and bare invocation renders state for most parents but not all, six of
them printing usage instead.

Every claim in the replacement was verified against a built binary by an
adversarial review, including that the list of six is complete.

Assisted-by: Claude:claude-opus-5[1m]
@REPPL
REPPL added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 375efa4 Aug 25, 2026
11 checks passed
@REPPL
REPPL deleted the fix/priority-sweep branch August 25, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant