Case study: auditing a multi-session estate as one system
Audit date: 2026-08-04. This is a method document. It describes how the controls in this repository were audited as a single system, what the audit was able to prove, and what it could not. It deliberately contains no status table, no finding list, and no inventory of what is or is not enforced on any particular machine. That information is a stale snapshot of one host on one day. Published, it becomes a map for the next reader who wants to route around the guardrails rather than use them.
If you want the current status of your estate, do not read a document. Run the audit:
pwsh -NoProfile -File bin/ccx-doctor.ps1
Everything below is the reasoning that command encodes.
Why the estate is the unit of audit, not the script
Each control in this repository is small and readable. Read scripts/hooks/worktree_gate.ps1 and you
can state what it denies. Read scripts/hooks/collision_gate.ps1 and you can state its posture. That
reading is worth almost nothing, because none of these files is what runs.
What runs is a copy. An installer places it outside every working tree. A matcher in a client config root invokes it. It resolves helper files it expects to find beside itself, and it executes on a host that may or may not have an interpreter for it. Every one of those joins can be wrong while every file involved is individually correct. And here is the property that makes this class of system unusually dangerous:
Every failure mode in this system is byte-identical to success.
A hook can be missing entirely, installed but not wired, wired but pointing at a script that no longer exists, or loaded but failing open. All of those produce the same thing a healthy hook with nothing to say produces: exit 0, no output, work proceeds. There is no error, no warning, no degraded mode. The session sees green. A stranger who has just cloned this repository and run one installer, or none, sits in exactly that state and will conclude the guardrails are working.
So the audit’s unit is the whole path from checkout to decision, and its currency is receipts.
The four-layer model
Any single control exists at four layers at once. Each layer answers a different question, and a green answer at one layer is not evidence about the next.
| Layer | The question it answers | Instrument | The failure that looks like success |
|---|---|---|---|
| Source | Does the rule exist in this checkout? | Read the file; run the test suite | The rule is merged, tested and green – and has never been installed anywhere |
| Installed | Is that rule in the copy the client executes? | SHA-256 of the installed artifact vs the source | Installed copy is days behind source. Reverse drift is equally invisible: delete a rule from source and the stale installed copy keeps enforcing it forever, while every test correctly reports it gone |
| Wired | Does anything actually invoke that copy? | Read live matchers out of every config root; diff them against the rules the installed script implements | A matcher exists but names a similarly-titled script from a different project; or a rule is implemented and no matcher ever reaches it |
| Effective | What does it decide when fed a real input? | Pipe crafted input at the installed artifact and read the emitted decision | Rules exit on first match, so a later rule may be structurally unreachable. A helper the script dot-sources is absent, so it exits 0 and enforces nothing |
Three consequences follow directly, and they are the three most expensive mistakes available here.
Merging a hook does not install one. Code can land arbitrarily far ahead of the call that installs it – sometimes deliberately, since a control may ship inert on purpose. Track inert-by-design separately from inert-by-accident, re-run the installer as a distinct announced step, and never count merged code as coverage. On the repo this tooling was developed in, a coordination hook merged and sat unwired for hours while the settings file looked entirely correct, because a similarly-named entry from another project occupied the slot it wanted.
Establish behavior by driving input into the installed artifact, not by reading source. The installed copy, the settings matcher and the source can all disagree with one another, and only one of them decides anything.
A control that cannot distinguish “ran and resolved” from “ran and found nothing” is not installed, however it looks. The hook that established this ran for weeks while printing a status message, and outlasted every other silent defect found the same day precisely because it printed something. The account, and the rule that falls out of it, are at put at least one signal outside the component being audited.
The drift taxonomy: D1-D4
“Drift” in this system is not one thing. Separating it into four classes is what makes an audit tractable, because the four have different instruments and different fixes – and because three of them are the reason the first one goes unnoticed.
| Class | What drifts | Symptom | Instrument |
|---|---|---|---|
| D1 – Session drift | Where work happens: a session builds in the shared checkout instead of an isolated one | Two sessions overwrite each other; a tree is swapped out from under a live session | Target-path gating at tool time; a SessionStart backstop that repairs and says so |
| D2 – Control drift | Which artifact enforces: source, installed copy and wired matcher diverge | Nothing. This is the silent class | SHA parity per layer; matcher-vs-implemented-rule diff |
| D3 – Coverage drift | What the rules can see: work moves to routes the rule set does not cover | A control is live, correct, and simply never invoked | Fire it on purpose; enumerate the routes; report every non-match you deliberately allow |
| D4 – Belief drift | What everyone thinks is true: docs, memory, status and premises diverge from behavior | Confident, wrong statements – including your own from last month | Re-measure the premise; date and attribute every figure; state status exactly |
D1 is the problem you set out to solve. D2, D3 and D4 are the reasons you believe you already solved it. An audit that only looks for D1 will pass.
On D3 specifically
Coverage drift has a structural cause worth naming: enumerated coverage means every hole is silent.
A rule keyed on a list of tool names, or a list of command verbs, is unmatched at both the settings
matcher and the rule body for anything not in the list – the control never runs, and nothing anywhere
says so. Prefer deny-by-default where you can. Where you cannot, ship a rule inventory and a -Status
that asserts against an expectation rather than printing a bare count, and log every non-match you
deliberately allow.
The same class covers routes rather than names. scripts/hooks/worktree_gate.ps1 inspects tool
arguments, so a file written by a shell command is not seen at all, and any agent-authored script
defeats a command-string gate – a script invocation carries no git token. That is not an
adversarial scenario; a sanctioned repair script is exactly that shape. Treat string-scanning gates as
guardrails against accidents, never as boundaries, and say so in the file. This repository shares one
command-splitting helper (scripts/hooks/_command.ps1) and one git-target resolver
(scripts/hooks/_gittarget.ps1) between the hooks that parse commands. The reason is itself an audit
finding: two hooks that each split commands their own way will disagree about what a command is,
and the one that drifts is the one nobody is testing. Keep exactly one copy of a safety check.
On D3’s opposite failure
False positives train sessions to route around the only control you have. On the repo this tooling was developed in, a verb-scanning rule denied a read-only status command because a blocklisted word appeared in a prose line of a multi-line command, and denied a commit whose message contained one. Every such denial erodes compliance with the deny text – which, on the shell path, is the only control there is. Scan per line, fold continuations, blank quoted spans, recurse into interpreter arguments, and ship ALLOW-asserting tests for the multi-line, echoed and message-containing cases. A gate that cries wolf gets routed around, and then you have nothing.
Six design rules the audit produced
1. Gate on the write’s target path, never the session’s cwd
The obvious design for “don’t build in the shared checkout” is to deny writes from sessions whose cwd is that checkout. It is wrong. Measured on the repo this tooling was developed in, over 30 days, 29% of write calls came from a session sitting in the shared checkout and landing inside a separate worktree by absolute path – already correct behavior. A cwd-keyed gate would have denied every one of them.
Key write-gating on the destination. A session may then stay where it is and simply write into its
worktree: no cd, no relocation, no restart. The price is that writes into another session’s
worktree are allowed. Accept that explicitly, and know the deny text actively teaches it.
There is a second, unobvious payoff. A target-path rule already contains a fan-out from a bad working directory: a subagent inherits its parent’s cwd, but its writes are judged by where they land, so they are denied at the destination regardless of where the parent was standing.
2. The gate’s own enforcement surface must be governed
The installed script and its allowlist live outside every governed checkout – which is deliberate, so that a checkout or a branch switch cannot make the gate vanish. The consequence nobody sees coming is that a path-keyed rule therefore returns “not governed” for the gate’s own files, and allows any session to edit them. Every session the gate governs could rewrite the gate.
scripts/hooks/worktree_gate.ps1 closes this with a dedicated rule (1a) covering its own script and its
own allowlist. Note what that rule is and is not: it stops a session from disarming the control,
while leaving a human at a plain terminal completely free to uninstall it. That asymmetry is the whole
design, and it is why the kill switch is documented in plain sight in the script’s own .NOTES rather
than hidden. Obscurity was tried and is not a control – the file is one directory listing away. Rule 1a
is the control.
Generalize it: any control with a mutable enforcement surface must govern that surface, and the governing rule must be evaluated separately from the rule it protects.
3. An unbacked backstop is worse than an admitted gap
A gate that cannot see the shell route needs a commit-time backstop, and one of these files once pointed at exactly such a backstop in its header. The backstop was a real, working, well-maintained dispatcher – of checks none of which implemented the predicate being relied on. The only actual control on that path was the deny text asking you not to route around it: persuasion, in a system whose entire premise is that persuasion does not work.
Verify that a claimed backstop implements the predicate you are relying on. If it does not, delete or caveat the sentence. An admitted gap is safer than a false one, because the next reader stops looking.
4. Evaluate prohibitions as a set, never one at a time
Two rules in this repository each look reasonable in isolation: deny fan-out dispatch from the shared checkout, and deny relocating a live session into a worktree. With both live, a session that opened in the shared checkout has no in-session path to isolation at all – it can neither dispatch nor relocate, and a human must restart it elsewhere. That is a hard stop on the way sessions naturally open.
Two rules, individually defensible, jointly a dead end. This is why scripts/worktree/install-gate.ps1
ships the relocation rule as an opt-in -EnterWorktreeGate switch that is off by default, and says
in the parameter’s own comment why: it is a decision to make on purpose, not one that rides along with
an unrelated install.
The corollary is ship the cure before the prohibition. If a prohibition removes the only path to the sanctioned behavior, the prohibition is the defect.
Two smaller rules in the same family:
- Re-measure a deny’s premise before defending it, and record its scope precisely. One rule here was remembered – in project notes, by everyone – as far broader than it was, because the case it covers happens often enough to feel general. Meanwhile part of its stated rationale had quietly expired as the surrounding tooling gained a capability the rationale assumed absent. A rule whose premise has expired is D4 drift wearing a control’s uniform.
- An install option that removes a control must leave a queryable trace. A flag that drops a rule silently recreates the exact observability gap the whole system exists to close. Here, the flag that skips a rule leaves it implemented but unmatched, which the audit then reports as a dead rule, and the installer prints a warning. If you turned it off, you should have to keep seeing that you turned it off.
5. A control with no receipts cannot be ranked, fixed, or defended
One gate wrote its decision to stdout and exited 0 – no log, no counter, no audit file – for its entire life. Nothing could answer “how many drift events were prevented last month”, “is the false-positive rate one a day or one in a thousand”, or “did the fix change anything”. Every severity claim about it, including which failure was most frequent, rested on intuition. With no receipts, every severity ranking is unfalsifiable.
Log every deny: timestamp, rule, tool, cwd, target, decision – never the raw command. It is smaller than any other fix on the list and it is the prerequisite for ranking the rest. A receipt stamped with a subagent’s process id is also what lets a parent session see what its fan-out was denied.
6. Prefer a control that acts and receipts itself; where you cannot, say so
One control in this repository is an instruction to the model rather than an action: it resolves peers and asks the model to deliver a message. Whether the message was delivered is therefore recorded by the model, not by the hook – it is the one control whose audit trail is written by the thing it is supposed to be evidence about. That is named as a permanent blind spot on every audit run rather than papered over. Where a control cannot receipt itself, say so explicitly, so nobody mistakes the trail for independent evidence.
Evidence discipline
This is the half of the method that is easiest to skip and most expensive to skip.
Green tests that bind the repo copy prove nothing about the installed one
On the repo this tooling was developed in, one gate had 85 passing tests. Every single one bound the repository’s copy of the script. Nothing anywhere read the installed copy or a live settings file. Enforcement was running from an installed copy that was days behind source, and the entire suite was green about it.
The fix is a test that skips unless the installed artifact exists. It then asserts SHA-256 equality
with the source, and that the live hook matchers superset the handled-tool list – and prints what
it scanned, so a skip never reads as a pass. This repository carries that as a tripwire
(Get-HandledTools in bin/ccx-doctor.ps1 reads the rule set out of the installed copy and diffs it
against every config root’s matchers), reporting three distinct states rather than one:
| State | Meaning |
|---|---|
UNWIRED |
The installed script implements the rule and no matcher ever invokes it – a dead rule |
STRAY |
A matcher invokes the gate for a tool the installed script ignores |
| off by choice | An opt-in rule that is off because somebody chose that, reported separately so it never hides in the same bucket as an accident |
Fire every control on purpose, and pair every attack with a negative control
Reading a control does not establish its behavior; feeding it does. bin/ccx-doctor.ps1 pipes crafted
PreToolUse payloads at the installed gate, attempts a blanket stage, attempts a commit claiming an
unheld work item, attempts a push to a protected ref – and requires a refusal in each case. Everything
runs against throwaway fixtures in the temp directory, with their own repositories, their own allowlist
and their own state root, deleted on the way out.
Each attack is paired with a negative control: an ordinary action the same control must allow. A script that refuses everything is not a working guard either, and more importantly a probe with no positive control cannot tell “the control refused correctly” from “the control refused because it could not load”. That distinction is not academic – the class of installer defect where the entry point is copied but the helpers it dot-sources or imports are not produces a control that refuses everything, for a reason unrelated to what it checks. Without a negative control that failure reads as perfect enforcement.
Attack results are downgraded to ??, never OK, when the artifact under test is the source rather
than an installed copy. Proving the rules work says nothing about whether anything is enforcing.
The probe is part of the system under test
The first version of this repository’s attack harness passed its payload under a parameter name that bound to nothing, silently, with no error. Four attacks fired payloads carrying a tool name and a working directory and no tool input at all. Every path-keyed rule correctly allowed them, and the audit reported the gate broken. The gate was fine. The probe was broken.
New-PreToolUsePayload now throws on an empty tool input, and the attack block catches an abort and
records ?? for the attacks that never fired – so a canceled sequence can never read as a silent
pass. A probe that cannot build its own input must refuse to report a verdict rather than report the
target’s answer to an empty question.
The general form: if your must-fail case and your under-test case produce the same output, the result is untested, not negative. Say so, and re-run against a known-good instance before trusting either answer.
Prove each fix by mutation
Passing tests do not show that the tests could fail on the defect. For each shipped gate fix in this corpus, five mutations were applied to the shipped artifact one at a time and each was required to go red. That exercise is also what surfaced three regressions an adversarial review found in the first attempt at one of the fixes – regressions that were then pinned with their own test file.
Mutate the shipped artifact deliberately, confirm each mutation goes red, and pin every regression adversarial review finds. Build the control, then attack it.
Test the real pair, not stubs
One fix in this corpus made an ordinary edit to an untouched file fail a gate – i.e. most edits – with a green test suite. The test stubs emitted a JSON shape the real helper never produced, so the tests validated an interface that did not exist. Run the real components together at least once before shipping a contract change between them. Stub-only coverage validates your assumption, not the seam.
Label figures you cited but did not re-measure
The 29% figure quoted above is the sole quantitative justification for the target-path design of the whole gate. Nothing in this repository can recompute it, and nobody has asked whether it still holds.
Cited numbers acquire the authority of measured ones as they get restated. Keep an explicit cited, not re-measured – treat with care section; strike claims through when superseded rather than deleting them; and if a number is load-bearing for a design, build the ability to recompute it. In this document, the figures in that category are: the 29% target-vs-cwd measurement, the 85-test count, and the per-prompt cost of the always-on coordination hooks. All were measured on the repo this tooling was developed in, none has been re-measured since, and every one of them is worth re-deriving before it is used to justify a new decision.
Writing the outcome down
Record rejected options with the specific blocking fact
The structurally strongest answers to “sessions keep building in the shared checkout” get re-proposed every single time the gate leaks: an OS-level sandbox, filesystem ACLs, a bare-repository layout, a repository-level worktree lock, a native path-deny rule in the client’s own permission system. Each is blocked by a concrete, statable fact – platform availability, what the lock primitive actually prevents, or the fact that a nested worktree layout lives inside the checkout a path-deny would have to cover. Another is the loss of the deny text, which on this design carries most of the rule’s value because it is the remediation channel.
Write the blocking fact next to each rejected option, not just the rejection. Otherwise the next session spends the cycle again and reaches the same place. And when a mechanism is genuinely unproven, timebox a spike that fails on purpose first rather than building on it.
The corollary is that deny text is a control surface and must be tested like one. In this corpus, a deny message refused a write to the shared checkout and then listed that same checkout first among the worktrees you could reuse instead, displacing a real one off a display cap. The cause: a filter compared a string against an object and was therefore always true. No test asserted on message content. Assert that the forbidden path never appears in the suggested-alternatives list.
State status exactly
The single most damaging line in an audit report is a bare Done.
The next session acts on the status. If a change is mostly done, the status must spell out what is not done, per item, in the same sentence: “Mostly done – X, Y; NOT done: Z.” An overstated status is worse than no status, because no status prompts a check and a false status ends one.
The same discipline applies to the audit’s own verdict. bin/ccx-doctor.ps1 exits 0 only when every
required control is installed and wired and refused every attack, 1 on any red, and 2 when at
least one check could not be determined. That last one is because a control that was not tested is
not a control that passed. -SkipAttacks therefore forces exit 2 by construction. The four-character
status tags are OK, RED, OFF, ?? and --, and ?? is not a rounding error toward OK.
Print what you scanned, and name your blind spots, on every run
Every run prints a WHAT WAS SCANNED block – config roots found, session records read, records that
could not be placed, worktrees enumerated, which interpreter – and a blind-spot block that prints
whether or not anything failed. An operator who believes they are fenced when they are not is worse
off than one who knows they are not.
What this method cannot tell you
Stated plainly, because a limits section that reads like marketing is itself a D4 defect.
| Limit | Consequence |
|---|---|
| PowerShell 7, Windows-first | Nearly every shipped script is PowerShell. Off Windows, process-table self-marking and path case-folding degrade, and the audit says so on every run. The Python part of the set – the git-hook checkers, the leak gate and the one substrate module they share – is stdlib-only and portable |
| The session-management MCP is Claude Code Desktop only | It is absent on a plain CLI install. Where it is absent, the announce hook still fires, still resolves peers, and then asks the model to call tools it does not have. PowerShell cannot see whether that MCP exists, so delivery is unprovable from here – it is printed as a permanent blind spot |
| The client’s session record schema is a vendor contract | Every liveness answer rests on a per-session JSON record written by the client. That schema can change under you without notice, and when it does the fence degrades quietly |
| The client-side session listing cannot see every session kind | Editor-hosted sessions in particular. A liveness signal built on it is incomplete by construction – which is why liveness in this repository may only veto a destructive action, never permit one |
| No interpreter, no gate | With no Python on PATH the git-hook shims fail open: they print to stderr and exit 0. The commit-time controls are off, and only the audit says so |
| Documented bypasses exist and are not closed here | A commit made with verification skipped bypasses both git hooks, and nothing local records that it happened. This is a guardrail against the accidental mistake, not a security boundary. Anything that claims otherwise is the unbacked-backstop defect in design rule 3 above |
| Scope | The audit examines one clone and the config roots it lists. It is not a machine-wide statement |
Two controls in this repository are, by their nature, only partly provable:
- The collision gate’s deny path needs a live peer worktree holding an uncommitted change to the same file. The audit proves only that it refuses to go silent when it cannot resolve – the fail-open path emits a distinguishable notice rather than the empty output that is byte-identical to all-clear. The deny itself is printed as a blind spot every run.
- The session banner makes no decision, so there is nothing to attack. It is reported by receipt and by live re-resolution only.
The audit loop, condensed
- Enumerate every control by receipt. Hash each installed copy against source. Read live matchers from every config root. Diff wired matchers against the rules the installed script implements.
- Fire each control on purpose and require it to deny. Against the installed copy, against throwaway fixtures, each attack paired with a negative control the same control must allow.
- Print what you scanned, always. A skip must never read as a pass; report it
??and let the exit code carry it. - Name your blind spots on every run, whether or not anything failed.
- Prove each fix by mutation before calling it a fix.
- Write the outcome exactly – including what is not done, which options were rejected and why, and which figures were cited rather than re-measured.
The one external reference this document depends on is public:
anthropics/claude-code#76590, the
half-failed automatic worktree behavior that the SessionStart backstop
(scripts/worktree/worktree-selfheal.ps1) repairs – and announces when it does, because a silent
repair is indistinguishable from nothing having been wrong.
A note on what is not in this document
The source material for this case study was a probe-verified bypass register: ranked, dated, and specific down to verified command strings and the exact surfaces that failed to cover them. It is withheld deliberately and permanently. Publishing an attacker index alongside the tooling it attacks converts a guardrail repository into a bypass manual, and the audience for the two is not the same audience.
What generalizes is the method, and the method is above in full. If you want the specifics for your own estate, they are one command away – and unlike a published register, yours will be current.