Repository navigation
v0.13.0 — security release: close the audited compass defects - #11
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
localhostNOT_EVALUATEDBash; other shell-executing tools ungatedexec()execFileargvThe 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_pendingis 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
fe8aa0c.lib/secrets.jsreverted reproduces 16 worse cells, so the harness can fail.npm testexit 0.Notes for review
MESSAGE_VERSIONisdecision-message/0.12.2— two branches changed the same contract and git merged them silently; the bump names both.deploy-prodremains quadratic (measured identical onfe8aa0c, 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