Skip to content

v0.13.0 — security release: close the audited compass defects - #11

Merged
templetwo merged 22 commits into
mainfrom
release/audit-fixes
Aug 3, 2026
Merged

templetwo merged 22 commits into
mainfrom
release/audit-fixes

Conversation

@templetwo

Copy link
Copy Markdown
Owner

Closes the findings from the Tier-31 security audit (claude.ai Opus 5 web seat, 2026-07-30), after three rounds of adversarial verification.

What this closes

# Finding Status
A Deny message handed the model the approval token; approval provenance unrecordable fixed (withdrawal deliberately NOT done — see below)
B MCP SSE bound all interfaces while logging localhost fixed
C Gate failed open on parse failure, throw, and timeout fixed — fail-closed floor + NOT_EVALUATED
D ReDoS in the shipped push pattern fixed — linear
E/F/J Quoted and long-form flag bypasses; reset token-adjacency fixed by normalization, not pattern widening
G Every rule scoped to Bash; other shell-executing tools ungated fixed — capability routing
H Shell injection via env interpolation into exec() fixed — execFile argv

The finding nobody had: C+D chain. classify() returns on first match and the vulnerable push rule was rule 2 of 8, so padding that stalled rule 2 meant rules 3-7 were never tested. A hard-reset command denied in 52ms; the same command behind 3,400 spaces took 6,229ms, blew the 5,000ms budget, and the call proceeded ungated. Now denies in 57ms.

Not done, deliberately: confirm_pending is NOT withdrawn. Verification established that withdrawing it converts PAUSE into WITNESS and deletes the soft-deny class — a design change reserved for the operator, not a security fix. Its two real defects (the token leak and unrecordable provenance) are fixed instead.

Verification

  • Exploits: 91/91 closed, 0 reopened, 0 new false positives. 66 were open on fe8aa0c.
  • Detection equivalence: 30.5M pattern-level inputs across the rewritten rules — 0 losses. A 127,747-call classify differential — 0 losses; all 8,036 differences are new denials.
  • No worse than base: an independent 360-cell grid (24 shapes x 3 sizes x 5 carriers, sha256-pinned payloads, real hook process, real budget) — 0 worse, 35 strictly better. A control with only lib/secrets.js reverted reproduces 16 worse cells, so the harness can fail.
  • Redaction safety: the url-auth change also drives secret masking, so equivalence was proven on capture groups and absolute spans, not hit/miss — 3,153,872 inputs, 0 divergences on five surfaces, against two baselines, on node 20/22/26. Two negative controls are rejected, including one that keeps detection identical but shortens the capture by one byte.
  • Suite: 611 assertions across 19 files, 0 failed, real npm test exit 0.

Notes for review

  • Merge order mattered and is preserved in history: C+D -> G -> F+J -> A -> B+H. G must not precede C+D (it routes new carriers into the gate before the budget exists).
  • MESSAGE_VERSION is decision-message/0.12.2 — two branches changed the same contract and git merged them silently; the bump names both.
  • Known and named rather than hidden: deploy-prod remains quadratic (measured identical on fe8aa0c, so not a regression; tempering it would change which commands match and needs its own equivalence proof), and repo-local policy regexes stay unbounded by design — they are the population the budgets exist to backstop.

Three rounds of adversarial review found: a fix that opened an input class it previously closed, a test that passed against a fully vulnerable program, a detection loss introduced by an anti-ReDoS tempering, and five worse-than-base cells invisible to both new cost suites because every pad they built was periodic. Each was caught by an agent whose only job was to break the previous agent's work.

🤖 Generated with Claude Code

claude and others added 22 commits July 30, 2026 17:14
…nitor

Finding B — mcp/server.js called listen(PORT, cb), which binds every
interface, while the startup line printed a hardcoded "localhost". The SSE
surface carries the full tool set (chronicle read/write, compass state) with
no authentication of its own, so the real bind was an off-box hole that the
log actively denied. Binds 127.0.0.1 by default — the host dashboard/server.js
already uses — with --host / T2HELIX_SSE_HOST for a deliberate override, prints
the address the kernel returned, and warns on stderr when that is not loopback.

Finding H — tools/witness-relay/monitor_workflow_memory.js interpolated
JETSON_USER and JETSON_HOST into a string handed to child_process.exec, i.e.
into a shell. Builds an argv array for execFile instead, and refuses values
that are not plain user/host names: with no shell, a leading '-' still lands in
argv[0] where ssh parses it as an option. Polling now starts only under
require.main so the argv builder is loadable without side effects.

Repo swept for the same shape: no other exec(), no spawn({shell:true}), no
shell scripts. The remaining child_process calls are argv-form spawn/spawnSync;
the other `exec(` hits are RegExp.exec and better-sqlite3 conn.exec (SQL).

Tests: test/sse-bind.js (4 of 5 fail on unpatched code, including an off-box
connect that succeeds) and test/witness-relay.js (6 of 6 fail on unpatched
code). Both wired into npm test. Suite 408 -> 419 passing, 0 failed.
…ial; record approval origin

The PAUSE deny reason is read by the party being denied, and it carried the
override credential verbatim — the gate volunteering the very thing that lifts
it. It now names the pending request by id and points at list_pending; the
credential never reaches a model-visible decision string. renderDecision still
takes opts.token, as a signal that a request exists rather than as content.

approveConfirmation also could not say where an approval came from, so an
operator-driven approval and a model self-approval left identical rows. It now
records approved_origin and approved_tty (additive migration; a NULL origin on a
pre-existing approved row reads as 'unattributed', never as a clean blank),
mcp/server.js hardcodes the origin 'mcp' so a caller cannot declare its own, and
the consumed-approval compass_log row states the pending id and the origin.

confirm_pending and list_pending are untouched as surfaces — withdrawing them
would convert PAUSE into WITNESS and delete the soft-deny class, which is an
operator decision, not this fix.

test/approval-provenance.js is the regression: 10 of its 12 assertions failed
before the change. Suite goes 408 -> 420 with no test removed or skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every rule declared tool:"Bash" and classify() skipped any rule whose
tool did not equal the tool name, so any OTHER tool that runs a shell
was never evaluated — and its row logged OPEN, byte-identical to an
evaluated clear. The repo-local rule protecting the chronicle db was
itself Bash-scoped, so the db was deletable through any other shell tool.

Rules now normalize to a CAPABILITY: tool:"Bash" (or an explicit
capability:"shell") guards shell execution wherever it happens. Rules
naming a different tool keep exact scoping, and tool_exact:true opts a
shell rule back out. A tool is shell-capable when its input carries a
string in one of SHELL_PAYLOAD_FIELDS (command, script), and that field
becomes the match haystack — so a rule anchored on a leading token is no
longer defeated by JSON quoting and a KEY name can no longer satisfy a
rule. summarizeAction() is untouched: it is the action:<hash> identity
that PostToolUse recomputes.

compass_log gains ruleset_hash, ruleset_source and rules_eligible, and
compass_rulesets stores each distinct ruleset's manifest once, in load
order, with a digest of every unredacted pattern. rules_eligible = 0 is
how the log says "never evaluated" rather than "evaluated and cleared";
NULL means the row predates the column. Migration is additive per column
so existing rows keep NULL rather than a backfilled guess.

The unknown-tool_name specimen in test/compass-consistency.js moves from
the UNMATCHABLE characterization block to a regression assertion — it was
recording this exact bypass, and all six seats now deny it.
classify() gained two throw sites when it started resolving the ruleset
hash and the eligibility count. Both ran before the match loop, so a
failure in either propagated out of classify() — and pre-tool-use's
fallback path calls classify() again and then emits a systemMessage,
which the host reads as an ALLOW. Describing a decision was able to stop
one being made, which is the fail-open shape this branch removes.

Metadata now resolves behind its own try/catch and degrades to null
(provenance unknown) while the verdict stands. Test drives a hash
failure and asserts the WITNESS survives with null provenance.
…position

Findings F and J. Three spellings of a gated command ran ungated: a flag in
quotes (`rm "-rf" x`, `git push "--force"`), a flag in its long form
(`rm --recursive --force`), and `--hard` anywhere but immediately after
`git reset`. All are the command the rule exists to gate, written differently.

F is fixed by normalizing the HAYSTACK, not by widening the patterns. Widening
was tried twice and reverted twice — once for rule-order shadowing, once for a
cubic idiom — so classify() now matches the command AS WRITTEN first and only
re-matches a normalized spelling when that pass came back clean. That ordering
is the safety argument: normalization can ADD a denial and can never change
WHICH rule fires on a command that already denied. Without it the revert
reproduces from the new site — measured DENY -> ALLOW under enforce for
`rm --force <file> && git push --force` and three sibling shapes, because
rm-rf-wildcard is rule #1 in a first-match-wins list AND a migrated family, so
the merge cleared the whole verdict once it captured the chain.

The two rewrites are narrow: a paired quote is stripped only around a
whitespace-free span, and long flags map onto the short letters through
LONG_FLAG_LETTERS — the same table the Action IR parser uses, so the two layers
cannot drift. Normalization reaches shell text only; a non-Bash tool_input is
JSON, where quotes are structure rather than syntax.

J re-anchors git-reset-hard with `(?:[^|;&\s]+[ \t]+)*`. The reverted attempt
used `(?:[^|;&]*\s+)?`, where [^|;&] is a superset of \s and three quantifiers
compete for one contiguous run: 95ms at 400 spaces, 181ms at 1000, cubic from
there against a 5000ms hook budget. Token-then-separator is unambiguous and
measures 0ms at 8000 spaces. [ \t] rather than \s so the walk cannot cross a
newline into the next command.

test/compass-normalization.js is new: the bypass specimens, the monotonicity
guard, and a 40-entry prose corpus kept as a permanent fixture with the real
pre-change verdicts — five of those are already WITNESS, and recording that
rather than an aspirational OPEN is what makes it a movement test. 20 of its 80
assertions failed before the change. Zero prose movement, verified again by
differential-classifying 1763 command-shaped strings drawn from the repo
itself: 28 moved, all OPEN -> WITNESS, all of them the targeted forms, no
relabels.

Three characterization tests in compass-consistency.js now assert the fixed
behaviour, following the convention already established there for the
2026-07-26 fixes. The linear-time test no longer exempts the reset shape.
Suite goes 408 -> 488 with no test removed or skipped.

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

Two defects chained into one working bypass. git-push-force competed three
quantifiers for a single contiguous whitespace run, which is cubic: 3,400 spaces
cost 6,093ms against a 5,000ms PreToolUse budget. And every internal failure in
the hook emitted a bare `{}` — byte-identical to an evaluated OPEN, with no
compass_log row. Prefix a destructive command with `git push` and enough padding
and the push rule ate the budget, the rules after it never ran, the harness
killed the hook mid-regex, and the call proceeded ungated. Measured through the
real hook at the real timeout: hard-reset and drop-table variants both killed at
5,000ms emitting nothing; now 1.6ms and 0.0ms to a deny.

- git-push-force: `(?:[^|;&]*\s+)?` -> `(?:[^|;&\s]+\s+)*`. Token-then-whitespace
  is unambiguous, so nothing competes for the run. Equivalence proven by an
  exhaustive walk of the token grammar: 715,236 inputs, 209,312 of them matches,
  zero divergence either way. It does NOT catch quoted `"--force"` — the
  2026-07-26 note claiming otherwise is corrected in place. Audited every other
  bundled rule; this was the only one in that ambiguity class.

- NOT_EVALUATED, a fourth classification for the decision the compass did not
  reach. Unreadable payload, no tool_name, a throwing classifier, a rule budget
  that ran out: all deny, all write the row that says evaluation did not happen.
  No override token — the bypass works by preventing evaluation, so a token there
  would let a caller stall the gate and approve past it. T2HELIX_GATE_FAILURE_MODE=open
  restores the old behaviour for these paths only and still writes the row.

- classify() runs on a budget, checked before each rule, and reports which rules
  never ran. It cannot preempt a regex already entered, so it is the backstop for
  repo-local policy rules rather than the fix for a bundled one.

- isClearance() replaces `=== 'OPEN'` at the allow/block sites in the hook and the
  Grok/Codex adapters, so a classification a caller has not heard of cannot fall
  through as allowed. grok-adapter no longer leaves a throwing classify at OPEN.

Full suite 428 passed / 0 failed, from a 408 baseline; the twelve existing suites
are unchanged in count. Six pre-existing tests asserted the old fail-open
contract and now assert the new one.
Round-2 close of the two CONCRETE_CHANGES_REQUIRED from the adversarial
review of fix/approval-provenance-and-token-leak.

R1 — the WITNESS guard in test/integration.js had gone vacuous. It
discriminated by !/confirm_pending/ on the deny reason; once the PAUSE
deny stopped naming that tool, the assertion was satisfied by both
classifications and could no longer tell an immutable floor from a
soft-deny. Rebuilt on three properties a PAUSE render cannot satisfy:
no pending confirmation is minted for the session, no [soft-deny]
prefix (derived directly from isPause, and preserved by the hook's
fallback reasonText), and the BLOCKED banner. Deliberately NOT the
"immutable WITNESS floor" sentence — the hook passes saferPathFor(),
and rm-rf-wildcard has a safer path, so that sentence is replaced on
exactly this specimen.

Proven by mutation, three mutants, each mapped to the assertion it
reddens: mint-for-any-non-OPEN -> the pending-length assertion; render
WITNESS as PAUSE -> the [soft-deny] assertion; both (the reviewer's
mutant) -> 19 passed/0 failed under the old guard, 18/1 under this one.

R2 — MESSAGE_VERSION bumped to decision-message/0.12.1. The message
contract changed materially while the identifier that exists to mark
that stayed pinned, so rows written before and after were
indistinguishable by the one field whose job is to distinguish them.
It is a contract version, not the package version; no test pins the
literal.

Also: the stale "the consumed approval's row id" comment in
lib/chronicle.js now describes the value actually written, and the
CHANGELOG records the three known-open items (grok-adapter still
returns pending_token, list_pending's optional session_id, and
self-approval via list_pending).

Suite: 420 passed / 0 failed across 13 files, npm test exit 0.
Round-1 review finding: test/witness-relay.js could not detect its own
regression. All six legs loaded the module with require.main !== module and
only inspected sshArgv(). A mutant that leaves sshArgv() byte-perfect and
rebuilds the CALL SITE around a shell —

  execFile('/bin/sh', ['-c', 'ssh ' + u + '@' + h + ' "' + REMOTE_READ + '"'])

with the startup guard removed — passes all six while a real injection fires.
Reproduced independently here: the round-1 file scores 6 passed, 0 failed on
that mutant, and JETSON_HOST='nonexistent.invalid; touch <marker> #' creates
the marker.

Adds three legs that run the monitor AS require.main, i.e. through the guard
and the call site:
  7  hostile JETSON_HOST  -> marker absent, exit 2, stderr names JETSON_HOST
  8  hostile JETSON_USER  -> marker absent, exit 2, stderr names JETSON_USER
  9  benign env           -> call site invokes execFile('ssh', sshArgv()) and
                            no shell binary (child_process stubbed via a
                            --require preload that records fn/file/argv)

Mutation-proved: 9/9 on shipped code, 6/9 on the mutant, with legs 7/8 failing
on the marker assertion itself (the injection really did fire), so the marker
check is a live positive control rather than an inert one. Cleanup of the
mkdtemp dir moved below the new legs — running it first would make `touch`
fail for filesystem reasons and turn the assertion green on a vulnerable tree.

Also, from the review's non-blocking notes:
- test/sse-bind.js test 5: /EADDRNOTAVAIL|exited/ -> /EADDRNOTAVAIL/. The old
  form accepts "server exited 1: SyntaxError: ..." — any crash read as "fails
  closed". Verified the real stderr carries EADDRNOTAVAIL, so this is free.
- tools/witness-relay/README.md: documents the plain-name restriction, exit 2,
  and the legitimate values (IPv6 literals, _gateway, dash-leading ssh aliases)
  the fail-closed validation now rejects.

No change to shipped code: monitor_workflow_memory.js and mcp/server.js are
byte-identical to 02dab00 (sha256 cb7e882d... on the monitor, verified after
the mutant was reverted).

Full suite: 14 files, 422 passed, 0 failed, 0 skipped, npm test chain exit 0
(419 baseline + 3 new legs).
Round-2 close of the two non-blocking notes from the adversarial review
of fix/dequote-normalization-and-reset-adjacency. No runtime code
changed this round — lib/ is byte-identical to bf0b81e.

1. LANDING ORDER. The normalized second pass re-runs every rule
pattern, which is free against a linear pattern and not free against
finding D's unfixed git-push-force pattern. One quoted token or one
long flag defeats the `normalized === haystack` skip, so the
superlinear cost is paid twice: the padding needed to blow the 5s
PreToolUse budget drops from ~3,200 to ~2,450 contiguous spaces, a
~23% discount on the C+D chain, and a blown budget is finding C's
fail-open. Captured two ways so it cannot be landed out of order by
accident — a prominent CHANGELOG note with the measured band, and a
CHARACTERIZATION test that detects whether D has landed and reports it
on every run. The test pins the MECHANISM as a ratio at 800 spaces,
not absolute milliseconds near the budget line, because an assertion
in the 2,450-3,200 band is flaky by construction.

Verified by mutation: replacing git-push-force with the linear
token-walk idiom (D landed) switches the test to its D-landed branch
and the amplification vanishes (1ms/0ms); removing the normalized
second pass drops the ratio to x1.00 and the assertion goes red.

2. THE '#' BOUNDARY. `git reset HEAD~1 # avoid --hard` moves
OPEN -> WITNESS: the token walk crosses '#' like any other
non-separator character. Recorded as a known limitation rather than
fixed. It fails SAFE, and the one-character remedy (excluding '#' from
the token class) is strictly worse — git refs and paths may contain
'#' mid-token, and it flips three real catches from WITNESS to OPEN.
Both halves are fixture specimens: the false-positive half is labelled
CHARACTERIZATION (a proper fix at the Action IR layer may update it),
the real-catch half is an unlabelled guard that must not go red.
Verified by mutation: applying the candidate pattern reddens all three
real catches plus the two characterizations, 6 failures.

Two bypass classes the reviewer identified are recorded as known-open,
NOT half-fixed: the `--flag=value` long-form (`rm --recursive=yes`),
and the escape/ANSI-C spellings (`rm \-rf`, `rm $'-rf'`). Their
legacy/shadow/enforce verdicts in the CHANGELOG table were measured on
this branch, not carried over from the review prose.

Suite: 495 passed / 0 failed across 13 files (488 before, +7 new),
npm test exit 0. Base fe8aa0c measured at 408/12 in this lane.
…change owes

Round 2. Closes the three attacks the adversarial review got through, plus the
two safety guards it showed could not fail for the bugs actually present.

1. PER-RULE HAYSTACK (the blocker, and a regression this branch introduced).
   matchHaystack() replaced the haystack for the whole CALL, so a rule that
   applies because the tool is NAMED in it lost the envelope it had always
   matched. Proven: Grok {op:"raw-mutate"} is WITNESS, and adding command:"ls"
   made it OPEN — a benign field converting a hard deny into a clearance, on the
   exact population (third-party policies carrying tool:"X") this fix exists to
   serve. Selection is now made inside the classify loop from HOW the rule
   reached the call: named-tool or unscoped -> the action string; reached by
   capability -> the shell payloads; both -> both, OR-ed. Identical strings are
   scanned once, so every bundled rule still scans exactly one string on a Bash
   call and the regex work (and the finding-D backtracking exposure) is not
   doubled. ruleMatchKind() is ONE computation consumed by both classify() and
   eligibility(), because rules_eligible is a logged value and two independent
   computations could drift into asserting that rules were evaluated when they
   were not.

2. EVERY SHELL-SHAPED FIELD IS MATCHED, not the first. detectCapability()
   returned on the first non-empty allowlisted field, so
   {command:"echo hi", script:<destructive>} cleared. The verdict now also
   carries matched_field, so a deny says which field it came from rather than
   reporting the first field by default.

3. REGEX STATE. A rule is now tested against more than one string, so a g/y
   pattern would make the verdict order-dependent. lastIndex is reset
   defensively and the stronger property (no rule carries either flag) is
   asserted in the suite.

4. THE TWO GUARDS, REBUILT AND PROVED BY MUTATION. The Grok guard exercised the
   trigger and the shell field in separate payloads and never in the same one;
   it is now a six-case matrix of the combination, generalized to an
   operator-authored rule scoped to a non-Bash tool. The false-positive guard
   was a Bash-vs-non-Bash PARITY test, so prose that denies under Bash too was
   invisible by construction; it now asserts ABSOLUTE OPEN over a corpus that
   includes prose, and the three measured prose false positives are recorded as
   known and accepted rather than claimed to be zero.

5. scripts/replay-compass.js reconstructed a non-Bash call as {raw: rest}, so
   every capability-gated deny replayed as OPEN and was reported as a softening
   — a fail-open in the instrument that measures fail-opens. It now parses the
   stored envelope, and counts a row whose summary was truncated as unreplayable
   instead of replaying it and manufacturing a false softening.

6. DOCS. The tool:"Bash" -> capability reinterpretation, tool_exact,
   capability:"shell", the known false positives and the operator levers are now
   in README, CHANGELOG, COMPASS_CONSISTENCY.md and the policy.json description.
   Stated accurately: tool_exact is NOT a way to narrow a bundled rule, because
   mergeRepoPolicy skips ids that already exist.

NOT closed here: the padded-regex stall (finding D). It is C+D's lane. The
capability path is placed inside the per-rule loop body precisely so C+D's
classify/rule budget wraps it; verified by building the merged tree and
measuring it (5003ms killed and ungated -> 60ms evaluated).

Suite: 43 passed in test/compass-capability.js (was 25), 451 across 13 suites,
every pre-existing suite at its exact baseline count.
Excluding an unreconstructable row from the softening/weakening counts is
correct — it is not evidence of drift — but on its own it DISARMS the tripwire:
a truncated row that replayed softer used to land in `weakenings` and fail
--strict, and would now land in `unreplayable` and pass. Coverage reporting is
not the same as keeping the decision honest.

--strict therefore has two non-zero exits: 1 for a real weakening, 3 for
incomplete coverage, with --allow-unadjudicable as the explicit opt-in for an
operator who accepts partial coverage. The human summary states coverage as a
fraction and names the first few unadjudicable rows, so a green run can never be
read as "no drift" over rows nobody judged.

Truncation detection is labelled precisely (length 201 = summarizeAction's 200 +
mark) but still refuses a shorter string ending in an ellipsis: the two errors
are not symmetric — over-refusing costs coverage and says so, while replaying a
partial command manufactures a false softening silently.

Also documents the deliberate asymmetry in matchTargets(): unscoped rules get
envelope OR payloads (they mean "match everything", and envelope-only would go
blind past actionString's 500-char cap on a long payload — that is how the
credential rule still reaches a secret pasted late in a long command), while an
exact-tool rule gets the envelope only and opts in with capability:"shell".

Proved by mutation: neutering the exit-3 branch turns the replay test red.
…udget

Round 2 of fix C+D. The adversarial review confirmed the fail-closed floor but
landed two working ungates against the round-1 tree and found that the pattern
rewrite had made one input class WORSE than the shipped code. All three are
closed here, and each claim below is a measurement, not an argument.

1. REGRESSION CLOSED — repeated "git push " prefix.
   Round 1's `(?:[^|;&\s]+\s+)*` was ~2.8x slower than the shipped pattern on
   this shape and moved the kill threshold DOWN from ~352KB to ~198KB. The rule
   now refuses to scan across a later `git push`, which makes it linear because
   a match crossing an interior occurrence is always available at that later
   occurrence instead. In-process at 198KB: shipped 1,995ms, round 1 6,127ms,
   round 2 0ms. Proved equivalent to the SHIPPED pattern over 1,065,220
   enumerated inputs (286,624 both-hits, 0 lost, 0 widened) with `git`/`push`
   added to the alphabet so the walk exercises the new construct.

2. THE 80KB SEMANTIC-LEG BYPASS CLOSED.
   The classify budget covered lib/compass.classify() only; classifyV2 then ran
   classifySemantic unbudgeted, and that leg was the whole cost (1ms legacy vs
   9,647ms semantic at 100KB). The leg is now skipped when it provably cannot
   move the verdict — legacy NOT_EVALUATED (absorbing), an oversize legacy
   WITNESS (top of the lattice, so the leg could only clear it), or a legacy leg
   that already spent the budget. parseToIR is kept unconditionally: it is
   linear and carries the hash the token flow keys on.

3. ROOT CAUSE OF BOTH SLOW LEGS FIXED. lib/secrets url-auth rescanned an
   unbroken token from every offset (17,683ms at 200KB, paid twice). Anchored
   with `(?<![a-z])`: 1ms. test() equivalence exact by enumeration (423,654
   inputs, 0 divergences) and group 1 — the masked span — unchanged, so the
   detector-implies-redactor invariant holds. no-verify got the same tempering
   as git-push-force (1,756ms -> 0ms at 200KB, 2,145,708 inputs, 0 divergences).

4. mergeDecisions: NOT_EVALUATED is now ABSORBING. Round 1 asserted this rather
   than reading it; under enforce, semantic documentation_only was returning
   active OPEN with false_positive_cleared:'classify-budget-exceeded' — a
   non-evaluation logged as a cleared false positive. A semantic PAUSE is
   equally refused: PAUSE mints an override token and this floor mints none.

VERBATIM EXPLOIT REPLAY (reviewer's harness, 5s timeout, SIGKILL, only the data
dir moved into this lane):
  reset+80KB pad     round1 5,003ms KILLED UNGATED -> round2   68ms deny
  git push x22000    round1 5,004ms KILLED UNGATED -> round2   84ms deny
KILL-THRESHOLD SWEEP to 2MB, both shapes: fe8aa0c goes ungated at 352KB (pad
shape: 105KB); this tree denies at EVERY size tested, 60-205ms. No input exists
where the shipped gate denies and this one goes ungated.

TEST now guards the CLASS, not the instance: a per-rule adversarial seed table
with a completeness assertion, four pad shapes, per-rule cost at 200KB, one arm
through classifyV2 so the semantic leg is inside the test at all, and the
end-to-end decision-reached property at both exploits' sizes. 5 of the 6 new
assertions fail against round-1 source (c4399e7), exit 1.

NOT FIXED, named rather than hidden: deploy-prod and repo-policy regexes stay
quadratic in trigger count (KNOWN_SUPERLINEAR). Measured IDENTICAL on fe8aa0c
and here — decision at 191KB, killed at 219KB on both trees, 3 runs each — so it
is pre-existing, not a regression. deploy-prod's read-only carve-out can block an
earlier trigger that a later one does not, so the tempering used above would
change which commands match.

Suite: 434 passed, 0 failed, 13 files. Every pre-existing file baseline-identical
(408 across 12 at fe8aa0c); +26 in the new file. policy-check PASS, doctor GREEN.
The compass-consistency ceiling relaxed last round is RESTORED to 500ms, because
the cause it named is the url-auth scan fixed here.

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

Self-inflicted, found on review of the previous commit and reproduced before
being fixed. semanticSkipReason's third condition — legacy leg already over
budget — carried no classification guard, so it fired for OPEN and PAUSE too.
Those are exactly the verdicts the semantic leg can ESCALATE (the rm-recursive
and credential predicates catch cases the string rules miss), so under enforce
an escalation could be dropped and, if the total stayed under GATE_BUDGET_MS,
the call would clear. Same fail-open shape this branch exists to close.

Measured before the fix, with T2HELIX_SEMANTIC_BUDGET_MS=-1:
  OPEN  -> semantic_skipped: legacy-leg-spent-budget:2ms>-1ms, semantic: null
  PAUSE -> semantic_skipped: legacy-leg-spent-budget:1ms>-1ms, semantic: null
After: both report semantic_skipped null with a real semantic verdict, while a
WITNESS still skips — so the gate did not simply stop working.

Every skip condition is now gated on isSkippable() — NOT_EVALUATED (absorbing)
or WITNESS (top of the lattice, so the leg could only clear it). Declining to
run the leg is therefore fail-closed by construction rather than by inspection
of each branch.

The existing assertion "OPEN must never be skipped" passed only because its
input was 20KB and legacy was fast, i.e. it asserted a general invariant using
an input that could not violate it. It now forces every skip condition true and
checks the invariant holds anyway. That addition fails against d3b550c
(25 passed, 1 failed).

Suite: 434 passed, 0 failed, 13 files, unchanged. Verified green BOTH ways —
with T2HELIX_POLICY_DIR blanked (the baseline-comparable runner) and with the
repo policy loaded (the npm test path), where the slowest 100KB pad through
both legs is 992ms against a 3,000ms ceiling.

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

The round-2 anti-backtracking guard used (?!git\s+push), but the outer
trigger requires (git\s+push\s+). A token 'git' followed by 'push<X>'
where X is not whitespace killed the earlier match and offered no later
start to restart from, so four executable force-push forms cleared on
this branch while denying on fe8aa0c and on c4399e7.

Requiring the trailing \s in the lookahead restores every loss:
enumerated 1,111,110 token-grammar inputs against the round-1 pattern,
0 lost and 0 widened; linear on both adversarial shapes (800KB
whitespace pad 2ms, 198KB repeated-prefix 0ms). Full suite 434/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hand-resolved lib/compass.js classify(): C+D's classify/rule budget now wraps
G's per-rule haystack selection rather than either replacing the other. The
per-rule clock spans the WHOLE target loop, not one regexTest, so a rule scanned
against several capability payloads cannot spend N x ruleBudget unrecorded.

Two integration-only corrections, neither of which git could have flagged:

  - The NOT_EVALUATED return now carries G's audit spread. lib/schema.sql defines
    a NULL ruleset_hash as "this row predates the column"; without the spread the
    one classification that means "never evaluated" would have been the least
    reconstructable row in the log, and mislabelled besides. Same reason,
    hooks/pre-tool-use.js emitNotEvaluated() now takes and stamps `provenance` at
    the three sites where a tool_input exists.

  - test/compass-consistency.js's closing NOTE still listed "(a) non-Bash tools
    that shell out are ungated" as UNRESOLVED. Git took C+D's rewrite of that
    block cleanly; G is the branch that closes (a). The note now states the
    integrated truth.

Verified at library level: a shell-carrying non-Bash tool call with a
20,000-space pad on the history-rewriting push idiom returns WITNESS in 0.03ms
(OPEN on the C+D-only tree), and the budget floor still fires with ruleset_hash
set and rules_eligible=8.
…t-fixes

Hand-resolved lib/compass.js. Three integration decisions, two of them silent
failures git merged cleanly:

1. NORMALIZATION IS A PROPERTY OF THE TARGET, NOT OF THE CALL.
   F+J's second pass was guarded by `tool_name !== 'Bash'`, for a correct reason:
   actionString() serialises a non-Bash tool_input to JSON, where quotes are
   structure. But G had just started routing `mcp__shell__run {command}` and
   `Terminal {script}` into the same shell rules, and those payloads are raw
   shell. Carried across verbatim, the guard left every tool G newly gated
   reachable by exactly the quoted spelling F+J exists to close: measured on the
   merge before the fix, a quoted recursive-delete flag denied through Bash and
   CLEARED through mcp__shell__run. Replaced by normalizeTarget(): a shell
   payload field is normalized whatever the tool is called; the action string is
   normalized only for Bash. Pinned by three new tests.

2. THE PASS BOUNDARY IS THE RULESET, THE BUDGET SPANS BOTH PASSES.
   classify() is now two scanPass() calls sharing one clock, one budget and one
   slow-rule ledger. A per-pass clock would have silently doubled the wall time a
   padded input can buy before the NOT_EVALUATED floor trips — the exact number
   the budget exists to bound. On the normalized pass a target that normalization
   did not change is skipped, so the integrated worst case is one extra scan per
   CHANGED string, not a doubling of every scan.

3. F+J's REWRITTEN git-reset-hard PATTERN IS SUPERLINEAR, and only C+D's
   linearity harness can see it — the two branches never met. Measured 5,590ms on
   a 200KB repeated-trigger pad, which is past the 5,000ms hook budget: the hook
   is killed, emits nothing, and the call goes UNGATED. Fixed with the same
   tempering idiom C+D applied to the other two git rules. Proven equivalent to
   F+J's pattern on 66,429 strings from an exhaustive token-grammar walk plus 12
   hand cases, zero divergences; 5,590ms -> 1ms.

Also: dropped F+J's `known-slow, measured above` skip in compass-consistency.js.
It existed because the padded specimen was slow on an unfixed D; with D landed
the specimen is fast, and skipping it would have dropped exactly the input the
merge order was chosen to protect.

End-to-end at the hook, integrated vs fe8aa0c: every non-Bash shell payload and
every quoted/long-form spelling now denies in 61-66ms where the base cleared it.
Suite 566 passing across 15 files, 0 failing.
MESSAGE_VERSION COLLISION RESOLVED. This branch bumped the contract to
decision-message/0.12.1 for its own change (the PAUSE next-step stopped carrying
the override credential and names the pending request id instead). The
fail-closed branch made a DIFFERENT material change to the same renderDecision
contract — a fourth classification with its own banner and its own next-step
branch — and did not bump. The hunks are disjoint, so git produced no conflict
and the tree would have shipped one version string naming two contracts, which
is exactly the confusion the field exists to prevent.

Bumped to decision-message/0.12.2 with a comment naming BOTH changes, and grepped
every occurrence tree-wide. Live sites: lib/decision-message.js (the constant) and
CHANGELOG's historical 0.12.0 section, which correctly describes what 0.12.0
shipped and is left alone. No test or consumer pins a literal — compass-v2.js
imports the constant — except the one stale consumer below.

STALE CONSUMER OF THE CHANGED CONTRACT (found by that grep, then by a red test):
test/compass-fail-closed.js scraped the override credential out of the deny text
to set up its approval-reuse case. That is precisely the leak this branch closed,
so the test could not pass on the merged tree. Rewritten to read the pending row
from the ledger, and it now asserts BOTH halves of the new contract — the
credential is absent from the deny reason, and the request handle #<id> is
present. That second assertion also pins pending_id as non-null at the hook: a
silently-null handle degrades the message with no test noticing.

THREE NEW CROSS-CONTRACT TESTS in test/approval-provenance.js. The no-leak
guarantee has to hold at renderDecision, not merely at the one caller that
respects it: no classification renders the credential, including NOT_EVALUATED
when a caller passes one; the NOT_EVALUATED branch is its own shape (not a
soft-deny, offers no override); and MESSAGE_VERSION is asserted at 0.12.2 with
the reason spelled out.

Suite 596 passing across 16 files, 0 failing.
Independent of the compass chain, as expected. Two conflicts, both mechanical:
package.json (union of the test chain — sse-bind.js keeps its mid-chain position
after sse-contract.js, witness-relay.js appends; 18 files total) and CHANGELOG
(consolidated in the release commit).

mcp/server.js is the one file this branch and the approval-provenance branch both
touch, and it auto-merged. Read the merged region rather than trusting that: the
two changes are genuinely disjoint — approval-provenance sets the approval origin
in the confirm_pending case and rewrites two tool descriptions near the top of
TOOLS; this branch adds the HOST resolution at the file head and the explicit
bind + honest startup line at the bottom. Both present and intact in the merged
file.

Suite: 595 passing across 18 files, 0 failing.

CORRECTION, carried here because it supersedes rather than erases: the two
preceding commit messages state suite totals I added up by hand and got wrong.
The per-file counts in them are right; the totals are not. Verified per step from
the runner output:

  C+D  13 files  434 passing   (commit message said 434 — correct)
  G    14 files  477 passing   (commit message stated no total)
  F+J  15 files  566 passing   (commit message said 566 — correct)
  A    16 files  581 passing   (commit message said 596 — WRONG)
  B+H  18 files  595 passing   (this commit; an earlier draft said 632 — WRONG)
0.13.0, not 0.12.x. Under the SemVer the changelog header claims, this is a minor
bump: additive schema columns plus a new table, new exported functions from
lib/compass.js, a changed operator-facing message contract, and a documented
BEHAVIOUR CHANGE for existing repo policies — `tool: "Bash"` now means the shell
capability, and it is opt-OUT.

CHANGELOG: four [Unreleased] blocks (one per branch that wrote one; the
fail-closed branch wrote none) consolidated into a single [0.13.0] section in the
project's existing Added/Changed/Fixed shape, with two sections the branches could
not have written:

  - Security, stating the six fail-open classes in the terms of what each one let
    through, since the through-line is one failure class and no single branch's
    block says so.
  - Integration findings, for the five defects visible only with all five fixes on
    one tree — the MESSAGE_VERSION collision, the normalizeTarget hole, the
    superlinear git-reset-hard pattern, the missing provenance on NOT_EVALUATED,
    and the discharged landing-order hazard — each with the measurement.

Known-open is a union of every branch's list plus what this integration found and
chose not to fix, including one that is pre-existing on fe8aa0c (README claims
eighteen tools, the registry exposes fifteen) and is therefore reported rather
than folded in. The historical [0.12.0] section is untouched, including its
`decision-message/0.12.0` reference — that line describes what 0.12.0 shipped and
is still true.

TWO ENTRIES REWRITTEN AFTER READING THE DIFFS RATHER THAN THE BRANCH DESCRIPTIONS.
The first drafts of the lib/secrets.js and lib/compass-v2.js bullets paraphrased
commit titles for two files this integration had not inspected — the only
substantial changed files taken on faith. Read and corrected:
  - secrets.js is a QUADRATIC url-auth pattern (4,772ms at 100KB, 17,683ms at
    200KB, paid twice), anchored with a scheme-start lookbehind proved
    test-equivalent by enumeration over 423,654 inputs, compared on capture groups
    so the detector-implies-redactor invariant is preserved.
  - compass-v2.js is not "a clock gate bug": the semantic leg could burn the hook's
    whole timeout AFTER a correct WITNESS was already computed (9,647ms at 100KB),
    so it is now declined only where declining is provably fail-closed, with OPEN
    and PAUSE never skipped at any size and the reason recorded in
    `semantic_skipped`.
Also added the two semantic-leg env vars and `semantic_skipped` to Added, which no
branch block listed.

Also corrected: the fail-closed branch wrote "0.12.1" as its anticipated PACKAGE
version in README (x2), CLAUDE.md and two test comments. That version will never
exist; retargeted to 0.13.0. The 0.12.1 mentions inside lib/decision-message.js
are about the MESSAGE CONTRACT and stay as written — they explain why the merged
contract is 0.12.2.

npm run release:doctor: changelog/package version agree at 0.13.0, README version
currency passes. The single remaining DRIFT is the pre-existing tool count,
identical on the base commit.

Final suite on the integrated tree, node v26.0.0, real npm exit code 0:
595 passing, 0 failing, across 18 files.
…y carrier

Capability routing gates carriers fe8aa0c skipped entirely. That is the point of
the release and also the one way it could cost something: a call waved through in
~55ms at any size is now scanned, and a slow scan becomes a denial or a killed
hook. Two cells failed that bar. Both are closed.

deploy-prod was quadratic in trigger count. Each kubectl|terraform|helm|aws|gcloud
occurrence re-ran three segment-wide scans. Hook-level, `kubectl apply x `
repeated:

  Bash             150K  base 1978ms ALLOW -> 2004ms NOT_EVALUATED   (a Bash regression)
  Bash             400K  base 5002ms KILLED -> 5003ms KILLED
  mcp__shell__run  150K  base   56ms ALLOW -> 2006ms NOT_EVALUATED
  mcp__shell__run  400K  base   58ms ALLOW -> 5003ms KILLED, no row

A budget cannot reach this: the whole cost is inside one uninterruptible
RegExp.test, so a check between rules never runs, and the parity floor below is
forbidden on Bash. The rule keeps its pattern as the SPECIFICATION and declares
matcher_source; lib/rules/deploy-prod-matcher.js evaluates it in linear time by
collapsing the three "does X occur at or after here" questions to one last-start
position per segment. The equivalence proof the previous round asked for is in
the suite: 2,284,440 generated inputs over the rule's own vocabulary, 11,235
both-hit, 0 lost and 0 widened. 1935ms -> 2ms at 150KB, 8ms at 800KB.

A presence check on the trigger word would not have done it — the blocked-tail
shape (`kubectl apply x ` repeated, then `|prod`) has the target present and
unreachable, and every trigger still has to be considered and still fails.

For rules lib/compass has never seen, the parity floor: compass.capabilityOnlyCall
reproduces fe8aa0c's eligibility line verbatim (!rule.tool || rule.tool ===
tool_name) and is true only when NO rule is base-eligible. On such a call budget
exhaustion returns OPEN / capability-scan-declined, naming the rules that did not
run, and the row is still written — losing it was the worst cell in the matrix.
Bash is excluded twice: every bundled shell rule names it, and the predicate
refuses it outright, so a user rules.json written entirely as capability:"shell"
(which REPLACES the bundled set) cannot turn the shell gate off on Bash.

R4 (prose in a command/script field) is answered as parity, not with a content
heuristic: on a tool that executes that field, such a string is a command, and
Bash has denied it since long before fe8aa0c. Pinned is that the verdict never
differs from Bash's, rule id included.

Receipts: 420-cell hook sweep (12 shapes x 7 sizes 20KB-800KB x 5 carriers incl.
Terminal/script, a future MCP tool name and the Grok envelope), fe8aa0c vs this
tree — 0 worse, 409 identical, 11 strictly better. Exploit set 51/51 closed here
against 36 open on fe8aa0c; token hunt across 14 tables, 0 leaks. Suite 611
passed / 0 failed, real npm exit 0.

compass.ruleTest(rule, s) is now the one way to run a rule: the cost test reached
past the engine to rule._regex.test, which is how deploy-prod stayed a named
residual in a suite explicitly watching for it.

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

Fix G routes shell payloads carried by non-Bash tools into the compass for
the first time. That charged them the cost of `credential-paste`, whose
url-auth pattern is quadratic on an unbroken run mixing LETTERS AND DIGITS:
each digit is a fresh restart point for a scan that runs to the end of the
run. fe8aa0c escaped only because the rule is tool-scoped to Bash.

An independent 630-cell grid found five cells where the merged tree was
worse than fe8aa0c, four of them the worst sub-severity — base ALLOWed and
logged in ~58ms, the merge SIGKILLed at 5,004ms emitting nothing and losing
the audit row (rows 1 -> 0), on an 800KB hex or alphanumeric payload.

Anchoring the match on the literal `://` and recovering the scheme with a
lookbehind pays the backward scan once per `://` instead of once per
character of the run. On Bash this cost was pre-existing, so this is a net
gain beyond the regression it closes.

EQUIVALENCE, proven on capture groups rather than hit/miss, because this
regex also drives redaction and match[0] deliberately shortens: 3,153,872
inputs, 0 divergences across five surfaces (test, looksLikeSecret,
redactSecrets output string, scrub output string, and group-1 value plus
ABSOLUTE span via the `d` flag), against both fe8aa0c and the
pre-linearisation commit, on node 20.19.6 / 22.20.0 / 26.0.0. Two negative
controls are rejected by the same harness, including one that keeps
detection identical but shortens the capture by one byte — leaking the
first character of a password into the log while every hit/miss assertion
stays green.

The two cost suites were structurally blind to this class: every pad they
built was PERIODIC, and a periodic pad measures ~2ms against this pattern
while an aperiodic letter+digit run measured 2,276-4,095ms at the same
size. Aperiodic pads are now fixtures in both suites.

Grid: 360 cells, 0 worse, 35 strictly better. Control with only
lib/secrets.js reverted reproduces 16 worse cells, so the harness can fail.
Exploits 91/91 closed, 0 reopened. Suite 611/0 across 19 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@templetwo
templetwo merged commit 309aa30 into main Aug 3, 2026
1 of 9 checks passed
@templetwo
templetwo deleted the release/audit-fixes branch August 3, 2026 00:34
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.

3 participants