Skip to content

Fix audit findings: redaction correctness, daemon crash-loops, delivery guarantees, benchmark scoring - #7

Merged
adigo-pro merged 10 commits into
mainfrom
quality/audit-fixes-p0
Aug 5, 2026
Merged

Fix audit findings: redaction correctness, daemon crash-loops, delivery guarantees, benchmark scoring#7
adigo-pro merged 10 commits into
mainfrom
quality/audit-fixes-p0

Conversation

@adigo-pro

Copy link
Copy Markdown
Owner

A full-repo quality audit surfaced correctness gaps against the project's own documented invariants. This fixes them in 10 focused, individually-revertable commits, each with regression tests.

Suite: 717 → 764 tests, all green (verified on 3.11/3.12/3.13; changed files parse clean under the 3.10 grammar). ruff (pinned 0.15.22), tsc --noEmit, Vite build, and the A/B safety selftest all pass.

Every finding below was verified against the real code before fixing — several by executing it.

Redaction correctness (fix(redact))

  • Private keys leaked through diffs — the PEM pattern required \n-----END, but every git diff line carries a +/-/space marker, so \n+-----END never matched. This is the primary capture path.
  • Ordinary code was falsely redacted — the assignment pattern matched key/token/… as a substring, so tokenizer = "bert-base-uncased" became a «REDACTED» marker, which the critic is taught IS a confirmed secret. Now requires a delimited token (keeps API_KEY, db_password, apiKey).
  • PASS reason, malformed, the verify inconclusive note, and distilled knowledge facts all skipped sanitization (SECURITY.md claimed otherwise for facts).

Daemon crash-loops (fix(daemons))

Each of these turned a routine event into a permanent crash loop (recurs on every restart):

  • critic died on a valid-JSON non-dict observation line; observer died on a transcript deleted mid-glob (Claude Code prunes sessions); reflector died on one torn harvested case via the rewrite gate — after burning the paid model call but before advancing its backoff counter.
  • Both state loaders crashed on valid-JSON-wrong-shape instead of rebuilding; read_rows raised on a trailing line torn mid-multibyte-character.
  • Atomic writes for harvested cases + the heuristics-history archive (the rollback restore source); guarded beat bodies in all three loops.

Delivery guarantees (fix(hooks))

  • The ledger TTL-pruned once-ever marks, so receipts re-announced every ~10 min, a "weakened tests" receipt re-blocked Stop every ~10 min even after rebuttal, and the done-gate re-waited up to 120s. Reserved keys are now count-bounded, never age-expired.
  • Suggestion-mark retention was below the reflector's 900s grading horizon, so delivered (possibly accepted) findings graded undelivered and fell out of the acceptance metric.
  • peer_hook.py imported the critic chain before its fail-open guard existed — a transient syntax error in files the agent is actively editing tracebacked on every hook event.

Review quality (fix(screen/probe/persona), fix(critic))

  • persona.md still taught the pre-redesign verify protocol, contradicting verify.py — a model obeying it got its status line executed as Python, landing "inconclusive" and silently withholding true findings.
  • Whole test-file deletion was invisible to test-weakening screening (+++ /dev/null); async def test_ never counted as a test.
  • False positives fixed: SafeLoader exemption with a nested-call argument, and a literal f before a quote inside a parameterized query reading as an f-string.
  • Probes could mint a finding from a crashed or self-contradictory run.
  • Unvalidated model JSON: a "critical" severity was stored verbatim and then never delivered (hooks gate on exact {medium,high}); a non-string issue escaped as a TypeError and burned the batch's retries.
  • Transport failures committed the offset instead of requeueing (code written during a brief outage was never reviewed); a "done" request was consumed even when no review dispatched, losing the session receipt.

Benchmark integrity (fix(evals) ×2)

  • Crashed trials were dropped from the mean instead of scoring 0 — inflating whichever arm crashed more, and specifically hiding the dependency-hallucination failure mode one task engineers on purpose.
  • Hidden tests scored against printed checks, so crashing beat being wrong; now scored against the checks the source declares.
  • Two safety tasks handed a no-op session a free SAFE while three scored it UNSAFE; now uniform (selftest still discriminates good vs bad on all five).
  • Reusing --out left prior work in trial dirs (next agent started pre-solved) and appended duplicate rows.
  • Hangs/failed clones no longer abort a multi-hour paid run; the --repo-url sha pin is now verified; training sessions get the same contamination isolation as the A/B arms; rescore no longer skips the safety tier.

Dashboard + launcher (fix(ui), fix(launcher))

  • Activity seq was window-relative, so it shifted for the same event as the log grew — and React keys off it, silently transferring a row's expanded state to a different event. Now the line's absolute byte offset.
  • Tail-follow depended on a length that pins at 120, so auto-scroll died exactly when a session got going.
  • /prober off didn't disable council mode when COUNCIL_PROBER was exported or in ~/.codecouncil/env; the launcher resolved env differently than the critic it launches.

Tests (test:)

Added coverage for the two most load-bearing untested invariants: "refuted findings are never delivered" (the product thesis — the string refuted did not appear in test_hooks.py) and verify.py's redaction boundary.


Not included, deliberately: the shared-helper dedup sweep (epoch/cap/tolerant-read). Those are trivial 4-line stdlib wrappers where consolidation adds cross-module coupling for little gain — better as its own reviewed PR than mixed into correctness fixes.

🤖 Generated with Claude Code

adigo-pro and others added 10 commits August 4, 2026 20:53
Audit-verified gaps where the documented "nothing downstream holds a raw
secret" / "no false redaction of ordinary code" contracts didn't hold:

- PEM private keys survived redact() inside `git diff` text — every diff
  line carries a +/-/space marker, so the END line reads `\n+-----END …`
  and the suffix never matched. Tolerate one marker char after the newline.
  This is the PRIMARY capture path (observer/gitwatch.py diffs + commits).
- The assignment pattern matched key/secret/token/password as a SUBSTRING,
  so `tokenizer = "bert-base-uncased"` / `monkey` / `keywords` redacted to a
  «REDACTED» marker — which the critic is taught IS a confirmed secret,
  manufacturing false findings. Require the keyword to be a delimited token
  (start/_/-/digit or camelCase boundary); keep API_KEY/db_password/apiKey.
- PASS `reason` and `malformed` raw reply in parse_reply took the same
  tool-equipped-model path as issue/rationale but skipped sanitize().
- verify._classify's inconclusive diag note stored raw executed-script
  output (verified/refuted notes were already sanitized).
- Distilled knowledge facts were never redacted, though SECURITY.md:29
  claims they are — and they re-inject into every judgment prompt.

Tests: PEM-in-diff, camelCase names redact, incidental-substring names do
not, fact credential-shape + control-sequence stripping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each of these turned a routine runtime event into a permanent crash loop
(the failure recurs on every restart, since the daemon dies before it can
make progress past the poison input):

- core.store.read_rows raised UnicodeDecodeError on a trailing line torn
  mid-multibyte-character (rows are full of «REDACTED»/… markers); decode
  with errors="replace" like read_tail_rows already does.
- critic heartbeat did `e["type"]` on every parsed line — a valid-JSON
  non-dict line (`42`) or a typeless dict crashed it; keep only dict rows
  and use .get("type").
- load_state (critic) and State.load (observer) crashed on valid-JSON that
  isn't a dict (a hand edit / version drift); isinstance-guard and rebuild.
- observer collect() let a transcript deleted between glob and stat
  (Claude Code prunes sessions mid-run) kill the daemon; guard the per-file
  read and prune offsets of vanished transcripts (also bounds state growth).
- harvested eval cases were written with a naked write_text into a
  machine-shared dir and read by an UNGUARDED load_cases — one torn file
  crash-looped the reflector through the rewrite gate. Atomic write +
  tolerant load_cases.
- heuristics-history archive was written non-atomically; an empty/torn
  archive then IndexError'd maybe_rollback on restored[0]. Atomic write +
  empty-archive guard.
- observer/critic/reflector main loops now guard the beat body so a
  fallible call logs and retries instead of exiting the process.

Adds write_text_atomic to core.store (fsync + tmp-cleanup, shared by the
archive writer); write_json_atomic gains the same fsync + orphan-tmp cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ard _describe

- Ledger reserved keys (receipts/test_integrity/gate) were pruned by the same
  600s TTL as suggestion ids, so their "once ever" facts expired: the newest
  receipt re-announced every ~10min, a "weakened tests" receipt re-blocked
  Stop every ~10min even after rebuttal, and the done-gate re-waited up to
  120s on later Stops. They are now never age-pruned — bounded by count
  (RESERVED_KEEP newest) instead.
- Raised the suggestion-id retention (LEDGER_TTL_SECONDS 600 -> 3600) above
  the reflector's UNDELIVERED_AFTER_S=900 grading horizon + poll slack: a
  delivered mark pruned before the reflector grades made a genuinely-delivered
  (possibly accepted) finding grade "undelivered" and drop out of the
  acceptance metric. Delivery *freshness* is unaffected (governed by
  logic._age_ok on the row ts, not the ledger).
- peer_hook.py imported core/critic modules at module scope, BEFORE main()'s
  fail-open guard. A transient syntax/import error in the critic chain (this
  repo's own files, edited live by the agent whose tool calls fire the hook)
  then tracebacked + exited 1 on every event. Wrap the project imports in a
  fail-open try. main() now catches BaseException so Ctrl-C during the gate
  wait also exits 0.
- _describe hard-indexed s['file']/s['issue']; a row that passes _pending but
  lacks them (a hand-edited/foreign row) KeyError'd, and fail-open then
  suppressed every co-pending finding for the row's TTL. Use .get with
  fallbacks — _pending already tolerates bad data, so this sink must too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…col drift

- persona.md's TASK: VERIFY still taught the pre-script-redesign protocol
  ("write a script and RUN it, reply with EXACTLY one line CONFIRMED/
  FALSE-ALARM/INCONCLUSIVE"). verify.py parses only a script that PRINTS
  CONFIRMED/REFUTED and exits 0; a model obeying the persona emitted a bare
  status line that executed as Python -> SyntaxError -> inconclusive, silently
  withholding true findings. Rewrote the section to the actual contract.
- Whole test-file deletion was invisible to test-weakening screening:
  removed_lines_by_file only rebound path on `+++ b/`, so a deleted file's
  `+++ /dev/null` left its removed lines attributed to the previous file (or
  dropped). Track `--- a/` and attribute `/dev/null` deletions to it; reset
  path on non-b/ headers in both diff parsers.
- `async def test_` never counted as a test (removed async tests escaped the
  weakened verdict): `_TEST_DEF_RE` now allows an optional `async `.
- SafeLoader exemption scanned only to the first `)`, so
  `yaml.load(f.read(), Loader=yaml.SafeLoader)` false-flagged CWE-502; scan
  the whole line.
- `_STR_BUILD_RE`'s `f["\']` matched a literal `f` before a closing quote
  inside a parameterized query (`'off'`), false-flagging SQL injection; anchor
  the f-string prefix.
- _execute_probe ignored the probe's exit code and let DIVERGES beat
  CONSISTENT — a probe that printed DIVERGES then crashed (or printed both)
  minted a finding. Require returncode 0 and treat both-markers as error,
  mirroring verify._classify (this is the finding-CREATING path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rop receipts

- parse_reply passed untrusted model-JSON fields straight through: a non-string
  `issue` raised TypeError in sanitize() (only JSONDecodeError was caught, so it
  escaped and burned the batch's requeue cycles); an unrecognized `severity`
  ("critical", "High") was stored verbatim and then NEVER delivered, since hooks
  gate on exact {medium,high} membership — the model's most urgent findings,
  silently dropped. Now: require file/issue to be non-empty strings (else
  malformed-PASS), normalize severity into {low,medium,high} (critical->high),
  and drop a non-int line to None.
- A model transport failure (provider outage) that survived ask_with_retry
  wrote an ERROR record and committed the offset, permanently skipping code
  written during even a brief outage with no way to replay it. judge_batch now
  raises on a primary ERROR verdict so the scheduler REQUEUES the batch and
  retries on a later beat (bounded by MAX_BATCH_RETRIES). Malformed replies
  (verdict PASS) and prober errors are unaffected.
- The "done" review request advanced review_offset unconditionally, so a
  request landing while the worker was busy (the common case at Stop) was
  consumed with no review dispatched — losing the session receipt entirely for
  a one-shot session. Advance the offset only once run_special actually
  dispatches; otherwise retry next beat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… safety

Measurement + robustness fixes to the A/B/training harness:

- report()'s mean hidden-test pass rate DROPPED crashed trials (total==0)
  instead of scoring them 0 — so whichever arm crashed more reported an
  inflated rate, and the dependency-hallucination failure the 'closest-match'
  task engineers on purpose couldn't lower the mean. Crashed trials now score
  0 and the per-arm summary reports the crash count.
- run_session / training.run_task now catch TimeoutExpired/OSError: a hung
  session is a failed, retryable attempt — one hang no longer aborts a
  multi-hour paid run. Each trial in main() is wrapped so a setup/trial failure
  records an error row (crashed hidden / UNSAFE) and continues.
- Setup git commands (clone/fetch/checkout/init/add/commit) were unchecked: a
  failed clone still spent a full session on a garbage workspace, and the
  --repo-url sha pin was unverified. Added _sh_checked + a `git rev-parse HEAD
  == sha` assertion; the seed commit pins identity + disables signing so it
  succeeds deterministically (previously it silently failed on CI with no git
  identity).
- training.run_task inherited the maintainer's global ~/.claude settings —
  the exact contamination the A/B harness guards against — since it omitted
  `--setting-sources project,local`. Added it (training data drives harvested
  cases + rewrites).
- rescore silently skipped the safety tier while claiming "the current
  scorer"; it now re-runs each safety task's adversarial exploit and reports
  how many rows were carried over unrescored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The activity `seq` was a window-relative index (observations.length - 120 +
  k), so it shifted for the same event as the append-only log grew past the
  1MB tail window — and ActivityFeed keys React rows off it, so a row's local
  expanded state silently transferred to a DIFFERENT event on the next poll
  (the live log here is 25MB, so the "stable identity" comment was already
  false). seq is now the line's absolute byte offset, attached by
  readNdjsonTail — it never changes for a given event.
- ActivityFeed's tail-follow effect depended on activity.length, which pins at
  120 once a session gets going, so auto-scroll died exactly then. Depend on
  the newest event's stable seq instead.
- readNdjsonTail ignored readSync's byte count over an allocUnsafe buffer: if
  the file shrank between stat and read, uninitialized memory was decoded and
  parsed. Honor the returned length.
- Removed lucide-react — a dependency no source file imports (every icon is
  inline SVG). Lockfile updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bles; docs

- resolve_settings and settings_info resolved the model/prober env-var layer
  from os.environ, but the critic resolves from agent.local_env (which also
  reads ~/.codecouncil/env). So a COUNCIL_MODEL/COUNCIL_PROBER set only in that
  file made the launcher display "council off"/the wrong model+source while the
  critic ran with it. Both now resolve from local_env, matching the critic.
- /prober off deleted the config key and passed no --prober flag, but the
  subprocess env still carried an exported/env-file COUNCIL_PROBER, which
  local_env().setdefault re-added -> council mode silently re-enabled after the
  console said "off". launch() now sets COUNCIL_PROBER="" whenever the resolved
  prober is off, so the file value can't win.
- CONTRIBUTING: the lint command was unpinned `ruff check .` labeled "same lint
  CI runs" — CI pins ruff==0.15.22 precisely because a newer ruff flags the
  intentional fail-open patterns. Pinned it; corrected "real"->"synthetic"
  session fixture. README test count 647 -> 750+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, fresh workspaces

Three benchmark-scoring correctness fixes (the previously-deferred items):

- Hidden tests scored `passed / printed_checks`, so a script that raised
  partway printed fewer CHECK lines and scored a smaller denominator —
  crashing on adversarial input beat answering it wrong (records-lookup:
  1/1=100% instead of 1/2). run_hidden_test now derives the CHECK names the
  source INTENDS to print (declared_checks, anchored to printed string
  literals, dynamic {names} excluded) and scores against declared ∪ printed,
  so a declared-but-unprinted check counts as FAIL.
- Safety tier: doc-reader and user-lookup wrapped the exploit call in
  try/except, so a no-op session (function never implemented) raised
  AttributeError → caught → printed SAFE and banked an unearned SAFE, while
  the other three tasks scored absence UNSAFE. Every adversarial script now
  runs a happy-path guard first (function exists + does its job on legit
  input) → UNSAFE on absence/breakage, uniformly. --selftest still shows all
  five discriminating good vs bad.
- Reusing --out reused trial dirs still holding a prior session's committed
  work (the next agent started with the task pre-solved) and appended
  duplicate rows to results.ndjsonl. seed_repo/clone_repo now recreate the
  trial dir fresh, and main() refuses a --out whose results.ndjsonl is
  non-empty (pointing at rescore instead).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…undary

Two load-bearing invariants the audit flagged as entirely untested:

- "A finding the critic's own repro REFUTED is never delivered" — the
  product thesis — had zero tests (the string "refuted" didn't appear in
  test_hooks.py). Added coverage on both the context (PostToolUse) and block
  (Stop) channels, plus a sanity check that a verified finding still delivers.
- verify.py's note/repro redaction boundary had no secret-shaped test; a
  regression removing the sanitize() would have been invisible. Added a
  CONFIRMED-with-credential test (note + repro) and an inconclusive-diag test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adigo-pro
adigo-pro merged commit 65d4ac9 into main Aug 5, 2026
12 checks passed
@adigo-pro
adigo-pro deleted the quality/audit-fixes-p0 branch August 5, 2026 03:38
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