Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,6 @@
| Recovery humility (idea-2) | `/abcd:disembark` and `/abcd:embark` surface docs carry the recovery-humility paragraph (≤4 sentences); embark closing imperative names "hunt the originating session before trusting the lifeboat blindly" |
| Capability scope (idea-4, itd-5 extension) | Every agent prompt frontmatter carries `capability_scope` with `task_classes` (closed-enum tokens) + `designed_for` (free-text 1-line); `internal/core/lint` validates set-membership at plan-review |
| Oracle seam contract preserved (idea-4) | Adding `capability_scope` does NOT modify the oracle seam (host-delegated by default, opt-in adapters — adr-25); the later-phase capability-aware routing (Frontier Awareness) is a pre-dispatch selector above the seam, not a modification to the seam contract |
| Surface discipline (sweep) | No `/abcd:<verb> show`, `<verb> stats`, `<verb> list` (plain), or `<verb> view` sub-verbs ship; bare invocation renders state for all `/abcd:<verb>` commands; lint `SD001` blocks on forbidden sub-verb introduction; earned exception preserved — each of `/abcd:capture list --open`, `/abcd:capture list --resolved`, `/abcd:capture list --wontfix`, `/abcd:capture list --all` (flag immediately adjacent to `list`, never pipe-joined into one token) |
| Surface discipline (sweep) | No `/abcd:<verb> show`, `<verb> stats`, `<verb> list` (plain), or `<verb> view` sub-verbs ship; bare invocation renders state for all `/abcd:<verb>` commands; `SD001` is reserved for this check but not yet implemented, so the discipline is enforced by review today, not by lint — and the shipped tree does not currently satisfy it: `abcd history show` and a plain `abcd history list` both ship, and `abcd banlist list` renders identically to bare `abcd banlist`, which is the redundancy the rule exists to forbid; bare invocation renders state for most but not all parents, `docs`, `history`, `disembark`, `embark`, `guard` and `ideate` printing usage instead. Those are the gaps a future `SD001` would have to admit or the tree would have to close; earned exception preserved — each of `/abcd:capture list --open`, `/abcd:capture list --resolved`, `/abcd:capture list --wontfix`, `/abcd:capture list --all` (flag immediately adjacent to `list`, never pipe-joined into one token) |

> **Open question (adr-35):** the *Disembark checkpoint* row above locates the interrupt checkpoint at `.abcd/logbook/disembark/<ts>/_state.json`. adr-35 makes disembark read-only with respect to the source repository and moves the operations namespace to `~/.abcd/voyage/<source-root-sha>/`, but it does not say where a mid-run checkpoint lands. The gate cannot be written until that is decided: the checkpoint must go either under the operator-level voyage home (alongside `disembark/history.jsonl`) or under the destination — it may not be written into the source tree, which the read-only gate hashes.
2 changes: 1 addition & 1 deletion .abcd/development/plans/2026-08-15-plugin-user-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ once. Human-paired (the §4 gate is manual by design).
or nothing.
2. **[iss-202](../../work/issues/resolved/iss-202-scanner-pii-config-unguarded-read.md)**
(critical) — `scanner.New` reads `pii.json` unguarded. Autonomous-eligible.
3. **[iss-203](../../work/issues/open/iss-203-audit-privacy-degraded-scanner-silent.md)**
3. **[iss-203](../../work/issues/resolved/iss-203-audit-privacy-degraded-scanner-silent.md)**
(major) — audit silently degrades when `scanner.New` fails. Same seam as
iss-202; land with or immediately after it, never in parallel.
4. **[iss-201](../../work/issues/resolved/iss-201-guard-hook-stdin-overflow-fail-open.md)**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
schema_version: 1
id: "iss-2608251011427187"
slug: "hooks-bootstrap-sh-and-hooks-json-still-deliver-sessionstart"
severity: "major"
category: "bug"
source: "agent-finding"
found_during: "adversarial review of the v0.6.6 priority sweep 2026-08-25"
found_at: "hooks/bootstrap.sh"
---

hooks/bootstrap.sh and hooks.json still deliver SessionStart notices by a non-zero exit, which the harness renders as an opaque banner with the text dropped. iss-2608241115201044 fixed the binary half: abcd hook session-start now exits 0, writes its notice text to stderr and only a CONSTANT to stdout. The shell half is unchanged and inconsistent with it. bootstrap.sh's notice() exits 2 on a REPORTED CONDITION including a successful install, and hooks.json's SessionStart chain propagates that ($s) plus a trailing exit 2 on the missing-binary branch. Under the same premise those messages are dropped too, so a fresh install's success line and the missing-binary complaint are both delivered by the mechanism the binary half abandoned. bootstrap.sh's own comment states the reasoning that makes the binary fix correct — 'a SessionStart hook's stdout becomes model context' — and an adversarial review demonstrated that concern concretely, planting a directive payload in a TRACKED config value that reached context when notices were briefly routed to stdout. So the shell must NOT simply move its text to stdout either. The open question is what channel a shell hook has left: a constant on stdout plus text on stderr mirrors the binary, but bootstrap.sh's exit code also governs the install path, and changing it touches the most safety-critical surface in the repo. Deliberately deferred rather than bundled into the binary fix.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "bug"
source: "agent-finding"
found_during: "bug-hunt loop round 9 (state issue #197), contract-fidelity hunt angle + independent adversarial verification"
found_at: "internal/core/audit/rule_privacy.go:87 (err == nil guard), internal/adapter/scanner/scanner.go:92-124 (New, degradation paths)"
resolution: "privacy-hygiene reports an unusable per-repo scanner config instead of absorbing it. scanner.New returns a nil error on every degradation path, so the err == nil guard was always true and its documented fallback was dead; the rule now consults sc.Unavailable() and emits an error Finding naming the config and the reason. An adversarial review found the contract still bypassable one character away — blanking a new pattern's regex dropped the detector with no Unavailable() and no finding, and abcd lint then reported conforms at exit 0 over a file it had been catching — so mergeConfig refuses an empty regex on a new pattern name, bounded to new names so a bundled-pattern severity raise is unaffected. Both directions are pinned."
impact: fix
---

audit's privacy-hygiene rule guards on 'if sc, err := scanner.New(ctx.RepoRoot); err == nil' expecting a fallback to the built-in pattern set on error, but scanner.New never returns a non-nil error — every degradation path (unreadable/unparseable/uncompilable pii.json override) returns unavailable=true with a nil error instead — so the guard is always true, the documented fallback branch is dead code, and the rule never calls sc.Unavailable(); a broken per-repo severity override (malformed JSON, a directory at the config path, or an uncompilable override regex) silently drops the repo's raised severities and downgrades the audit exit code from 2 to 1 with zero finding or diagnostic explaining why, unlike history.Capture and the CLI identity path which both check Unavailable() explicitly on the same scanner. Independently verified via two-fixture test (valid override raises severity to error/exit-2; broken override silently drops to warn/exit-1 with no diagnostic) for all three degradation branches (malformed JSON, directory at path, uncompilable regex)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "bug"
source: "user-observation"
found_during: "sessionstart-hook-error-investigation"
found_at: "internal/surface/cli/cli.go"
resolution: "abcd hook session-start exits 0 and delivers its notices where they are read. It returned exit 2 on the belief that a non-zero SessionStart puts stderr in front of the human; the harness renders an opaque startup hook error banner and drops the text, so every notice arrived as an error with no content. The notice text goes to stderr and only a CONSTANT plus a count goes to stdout, naming abcd history staged and abcd ahoy. That split matters: SessionStart stdout is injected into the session context, and an adversarial review demonstrated a directive payload reaching context through meta.setup_version in the TRACKED .abcd/config.json when the text was briefly routed there. bootstrap.sh's own comment had already recorded the reasoning. The shell half of the same premise is deferred as iss-2608251011427187."
impact: fix
---

SessionStart exit-2-as-advisory no longer reaches the user. abcd hook session-start returns exitError{Code: 2} whenever it has notices to print (internal/surface/cli/cli.go:1289), on the documented assumption that a non-zero SessionStart surfaces stderr without blocking. Claude Code v2.1.241 instead renders it as 'SessionStart:startup hook error' followed by a truncated echo of the hooks.json command string; the stderr notice itself never reaches the user. Every session-start notice is therefore an opaque error banner: transcript-capture gaps, staged-drain failures, the remaining-backlog count, binary skew, dogfood staleness, and the version transition. The data-loss-adjacent ones matter most - a user told that a session transcript was not captured now sees only a hook error, with no way to learn what it was.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "process"
source: "agent-finding"
found_during: "v0.6.5 docs-currency release gate 2026-08-24"
found_at: "Makefile"
resolution: "the preflight gate list is derived from the Makefile recipe and checked against every surface that restates it — the Makefile's own comment block, the install guide, CONTRIBUTING.md, AGENTS.md and its CLAUDE.md mirror. The detector immediately caught two live instances in AGENTS.md, which said three gates in one place and four in another. 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, since rewriting a sentence to name two of five gates removes the sentinel with them; and the Makefile subtest read the whole file including the recipe line it derives the expected set from, so it could not fail. Both are closed and both defeats are proven to fail now."
impact: fix
---

the preflight gate list is prose no test derives from the recipe, so it drifts every time the recipe changes. Makefile's preflight target names its prerequisites once; docs/how-to/install.md, CONTRIBUTING.md and the Makefile's own comment each restate that list by hand. The restatements have now drifted twice in two releases: v0.6.4 corrected three surfaces that said three gates after lint-issues made it four (2852c095 updated AGENTS.md alone), and v0.6.5 re-introduced the same defect by adding site-render as a fifth prerequisite and leaving those surfaces saying four. Both times only the host-run docs-currency reviewer noticed, and both times it refused a release cut over it. The class is a derived-value restatement with no deriving test: the same shape as the marketplace slug, which TestInstallGuideDocumentsTheInstallAndUpdatePath solved by reading go.mod and asserting the documented string contains it. The detector is the same move here — parse preflight's prerequisites out of the Makefile and assert each enumerating surface names exactly that set. Acceptance corpus: the two instances above, both of which the test must flag when replayed against their pre-fix trees.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "bug"
source: "impl-review"
found_during: "pr-294-review"
found_at: "internal/core/capture/parse.go"
resolution: "capture and record-lint now part bare from quoted YAML nulls identically. A bare null spelling normalises to the empty string at parse time while a quoted one stays the string it spells, so the nullness decision is made while the RAW scalar is still in hand — record-lint tests the raw scalar, and capture used to test the unquoted one. Widening IsNull for iss-287 made that split wider rather than narrower, which is why the two land together. An adversarial review found the empty spelling still split: impact: with no value took the nested-object branch and parsed to a map, which capture rejected as must be a string while record-lint read it as null. A lookahead now parts them — an object has an indented member, a bare key: does not."
impact: fix
---

Quoted YAML nulls split capture and record-lint verdicts: capture unquotes before IsNull, record-lint reads the raw value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "bug"
source: "user-observation"
found_during: "pr-294-review"
found_at: "internal/core/frontmatter/frontmatter.go"
resolution: "frontmatter.IsNull is the YAML 1.2 core null set exactly — empty, ~, null, Null, NULL — and deliberately not a case-insensitive compare, because YAML does not accept nUlL and reading records no parser agrees with is worse than the miss. internal/core/lint's private duplicate, which recognised only the lower-case spelling, now delegates to it: that duplicate was the split where impact: NULL was null to capture and a malformed impact to the lint."
impact: fix
---

Uppercase YAML null spellings unrecognised by the null predicates (reported as GitHub #290)
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ Start with the plan and the design record:
Run from the repo root.

```bash
make preflight # the pre-push gate: lint-reviews + record-lint + docs-lint,
# then build + vet + test + race (internal)
make preflight # the pre-push gate: lint-reviews + lint-issues +
# record-lint + docs-lint + site-render, then build + vet +
# test + race (internal)
make build # cross-compiles bin/abcd-<goos>-<arch> (there is no plain bin/abcd)
gofmt -l . # format gate: any output names a file needing `gofmt -w`
go vet ./... # static checks
Expand Down Expand Up @@ -151,8 +152,8 @@ irreversible; guessing downward costs nothing.**

## Definition of done

- `make preflight` is clean — the four lint gates (`lint-reviews`,
`lint-issues`, `record-lint`, `docs-lint`) plus `go build ./...`,
- `make preflight` is clean — the five gates (`lint-reviews`, `lint-issues`,
`record-lint`, `docs-lint`, `site-render`) plus `go build ./...`,
`go vet ./...`, `go test ./...`, and `go test -race ./internal/...`.
- `gofmt -l .` reports nothing. The format gate is CI's own step, outside
`make preflight`, so run it before pushing.
Expand Down
12 changes: 11 additions & 1 deletion internal/adapter/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,17 @@ func (s *Scanner) mergeConfig(cfg Config) error {
}
def := cfg.Patterns[name]
if def.Regex == "" {
continue
// A NEW pattern with no regex detects nothing, and skipping it
// silently is the one weakening a broken config can still achieve
// without being reported: an adversarial review demonstrated a
// pull request blanking a repo's custom detector's regex, after
// which `abcd lint` reported "conforms" at exit 0 over a file the
// detector had been catching. Bundled names never reach this loop
// (the floors check above continues past them), so refusing here
// cannot affect a config that only adjusts a built-in pattern's
// label or severity — it binds exactly the case where an empty
// regex is meaningless (iss-203's contract, closed here).
return errUnreadable("pattern " + name + " has no regex")
}
expr := def.Regex
if def.CaseInsensitive {
Expand Down
56 changes: 56 additions & 0 deletions internal/core/capture/nulls_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package capture

import "testing"

// capture and record-lint must reach the SAME verdict on one record's impact,
// whatever spelling and quoting it uses (iss-285, coupled to iss-287).
//
// record-lint tests the RAW scalar: a bare null is a null, and a quoted one is
// the string it spells. capture parses first, and parsing unquotes — so without
// normalisation the two gates disagree, and a record passes the lint and then
// fails the command that acts on it. Widening the null set (iss-287) makes that
// split wider, not narrower, which is why both fixes land together.
func TestBareAndQuotedNullsPartTheSameWay(t *testing.T) {
for _, c := range []struct {
name string
line string
wantNul bool
}{
{"bare lower", "impact: null", true},
{"bare title", "impact: Null", true},
{"bare upper", "impact: NULL", true},
{"bare tilde", "impact: ~", true},
{"bare empty", "impact:", true},
{"quoted lower", `impact: "null"`, false},
{"quoted upper", `impact: "NULL"`, false},
{"quoted tilde", `impact: "~"`, false},
{"a real impact", "impact: fix", false},
} {
t.Run(c.name, func(t *testing.T) {
fm, err := parseFrontmatterBlock([]string{c.line})
if err != nil {
t.Fatalf("parse %q: %v", c.line, err)
}
// Assert the TYPE before the value. `got, _ := v.(string)` yields ""
// for any non-string, so without this the empty case passed for the
// wrong reason — `impact:` with no value parses to a map, not a
// string, and the assertion silently swallowed it.
raw, present := fm["impact"]
if !present {
t.Fatalf("%q produced no impact key at all", c.line)
}
got, isStr := raw.(string)
if !isStr {
t.Fatalf("%q parsed impact to %T (%v), want a string — a non-string here "+
"is rejected by capture and read as null by record-lint, which is the "+
"split this test exists to close", c.line, raw, raw)
}
isNull := got == ""
if isNull != c.wantNul {
t.Errorf("%q parsed to %q (null=%v), want null=%v — capture must part "+
"bare from quoted exactly where record-lint does",
c.line, got, isNull, c.wantNul)
}
})
}
}
Loading