Skip to content

The three layers

CommitCycle works in three layers. One advises, one blocks at the tool call, and one checks the pull request after the work exists. This page is for deciding how far to trust each of them: what it catches, how it fails, and what it cannot catch. The failures below come from CommitCycle’s own measurements and records, and where something was never measured, the page says so.

What it is When it acts What it can stop
Layer 1 — context The agent guide: AGENTS.md, plus a gitignored CLAUDE.local.md for the task in progress Before the agent acts Nothing. It is advice.
Layer 2 — the hook A process the editor runs before every tool call At each tool call The call, before it lands — seen in Claude Code only
Layer 3 — the CI check A status check on each pull request After the work exists A merge, and only where the check is required. CommitCycle’s own repository cannot require it.

The agent guide is two files. AGENTS.md is committed and says what holds for every task: the board the repository answers to, the protected zones, and that a blocked agent asks for access with a reason instead of looking for another route. CLAUDE.local.md is gitignored and says what holds for the task in progress: what it should do, what it must not do, which zones are open to it, which are closed, and which playbooks to read. cycle start writes it, and handing the task in clears it.

Claude Code does not read AGENTS.md by itself: cycle init makes CLAUDE.md import it, and Claude Code loads CLAUDE.local.md directly.

Nothing. It blocks no write and observes no call. Its job is to put the rules in front of the agent, in words the agent reads, before the work starts.

  • Nothing checks that it was followed. The hook decides from the branch, the task and the grant on disk, with nothing from the session’s context, so a wrong line in the guide cannot open anything the grant does not.
  • It can be confidently wrong. On 2026-08-05 a session’s guide was rewritten for a different task while that session kept working. On 2026-08-29 the guide told a session that no software on its machine was checking it, while the hook was denying that session’s writes; whether the machine is enforcing is now said only in the gitignored file. And after the task half moved out of AGENTS.md, older installed copies of the CLI kept writing task blocks back into it.
  • An agent that reads the guide and does something else.
  • An editor that never loads the file. Claude Code loads CLAUDE.local.md; whether Codex, Cursor or Windsurf read it has not been checked, so do not count on the task half of the guide reaching those editors.

The hook is a process the editor runs before each tool call. It works out which task the call belongs to from the branch that is checked out, reads that task’s grant from local files, and answers allow, ask or deny — without a network call and without anything from the session’s context.

The eval harness drives the built hook from outside it on every CI run, and asserts that:

  • a write into a zone the task’s grant does not open is denied;
  • a read of a zone closed for reading is denied, without its contents leaking;
  • read access never turns into write access;
  • with no task on the branch, every write is denied and reads stay open;
  • an expired grant stops working, with no network involved;
  • a broken hook, or one fed garbage, denies rather than letting the call through;
  • twenty ordinary commands stay unblocked;
  • the hook and the CI check reach the same verdict on the same diff.

Beyond that list: a shell command that changes a tracked file outside the grant is reverted after it runs (why); the hook’s own state, where grants are read from, is refused to every tool write whatever grant is held; and in Phase 0, a write delegated to a Claude Code subagent passed through the hook too.

  • The editor lets the write through when the hook fails. Phase 0 ran real headless Claude Code sessions (version 2.1.218) and recorded whether the file changed on disk. A hook that hung past its 5-second timeout, crashed, printed something other than JSON, or pointed at a script that was not there: in all four cases, the write went through. Cursor, Windsurf and Codex document the same default, and only Cursor documents a setting that fails closed.
  • The wrapper closes three of those four, and cannot close the fourth. CommitCycle runs the hook behind a wrapper that always answers, and answers deny when the hook’s core cannot. A hook script that is missing never runs at all, and the session gives no sign: deleting one file switches Layer 2 off.
  • Deny is the default answer to a failure, not the only one. What the wrapper does when the core cannot answer is a per-repository setting, chosen on the board by a person who is signed in: closed denies every call, and is the default; reads lets a fixed list of tools through and says so in each answer; journal lets every call through, records it as unwitnessed, and falls back to closed if it cannot record. An agent can read the setting and can never write it.
  • An answer on a channel the editor does not read is not an answer. Windsurf takes a denial only as exit code 2. Driving the wrapper itself — not a Windsurf install — showed it exiting 0 on a Windsurf-format denial, including the denial it gives when the core is missing, so by Windsurf’s contract both were allows. The wrapper now exits 2 for both. What any editor other than Claude Code does with the hook’s answer has never been observed; see what blocks in each editor.
  • A session enforces the hook it loaded, which can be older than the source. The editor loads the hook bundle once per session, so a rebuild changes nothing until the next session, and a plugin install carries its own version into every repository on the machine. Twice, on 2026-08-18 and 2026-08-22, the process judging tool calls was running code from an earlier week while every test passed. CI now fails when the shipped bundle is older than the hook source, and cycle doctor names the bundle a session is running.
  • A machine that cannot run the wrapper enforces nothing. cycle doctor used to call such a machine installed, because it checked that the bundle existed rather than that anything could run it. It now runs the wrapper to find out. Which platforms enforce today is on the install page.
  • Work where the hook does not run: an editor with no hook file, a hook file the editor never loads, a missing script, or a person editing by hand. The hook sees an agent’s tool calls and nothing else.
  • Files git does not track. Containment reverts against a git snapshot, and a snapshot cannot see a gitignored path — so a gitignored file inside a protected zone, an .env file for instance, gets no containment. How a zone holding secrets should be protected is still an open question.
  • What a shell command will do, as opposed to what its command line says. Shell refusals, for reads and writes alike, reason about the command line. A write the snapshot can see is reverted whatever the command line said. A read cannot be reverted, and neither can a write to a path the snapshot cannot see, such as the hook’s own gitignored state: there the command-line rule is the whole defence, and for that state it has been narrowed and widened four times, with a way past it measured and not recorded as closed.
  • Which agent made a call. The hook binds by branch, so two sessions in one working directory share one task and one grant. It happened once: four commits from one session were authorised against another task’s grant. See two sessions at once.
  • An organisation that turns hooks off. Claude Code’s managed settings let an organisation allow only managed hooks, which blocks this one outright.

Layer 3 is a job that runs on each pull request. It finds the task from the branch name — the same binding the hook uses — recomputes the diff from git, asks the board which zones that task declared, and fails when a changed path sits in a protected zone the task did not declare. The code that judges is built from the branch the pull request wants to merge into, not from the pull request.

  • A pull request that reaches into a protected zone its task never declared, whether or not a hook was running while the work was done: it reads the diff, not the laptop’s log. It is a separate job so that it keeps running when the other checks break, because it exists for the repository where the hook was never installed.
  • A pull request that edits the checking code is still judged by the copy on the branch it wants to merge into, until it merges.
  • A board that does not answer makes it pass. Without the board it cannot know what the task declared, so it skips that comparison — loudly, and green.
  • It certifies itself. On a pull request from a branch in the same repository, GitHub runs the workflow file as that branch defines it. Building the judge from the target branch does not change this: the branch under check can rewrite the job that checks it.
  • It judges the declaration, not the grant. It does not check whether anyone approved the declared zones, whether the grant expired, whether its signature holds, or what state the task is in, and a declaration can be widened after the approval it passed under.
  • It reads the zone map from the pull request. A branch that edits the map edits what it is judged against.
  • It cannot be made required on CommitCycle’s own repository. GitHub refuses branch protection there, on a private repository under a personal free plan, so a red check does not stop a merge. Until a check can be required, what gives this layer effect is a person who merges only on green.
  • Anything that never becomes a pull request, such as a push straight to the trunk.
  • A write as it happens. It runs after the work exists.
  • A change inside a zone the task declared, whether or not anyone approved that zone.

Two pieces meant to close these gaps exist and are not in use:

  • A stricter mode of the check. It fails when the board is silent, judges the grant the gate recorded rather than the declaration, reads the zone map from the branch being merged into, and has no override. It is implemented and tested, and CI does not run it.
  • A check run from outside the repository, which a branch could not rewrite. Its receiving half is built on the board’s side; nothing has been registered for it, so it refuses every delivery.

This is the fourth failure mode followed to the end. A session with no hook installed leaves no events and meets no denial, and Layer 2 cannot see it by construction. The closing gate finds a diff with no mutation events — which is also what a person editing by hand looks like — so it warns rather than refuses. Layer 3 still compares that diff with what the task declared: if the check runs, if the board answers, and if someone heeds a red result, because on CommitCycle’s own repository nothing requires it.

Several editors document a pre-tool-call hook that can deny, and Claude Code also ships path-pattern deny rules at no cost. If CommitCycle’s argument were that it can block a write, the honest answer would be that you already can.

What a native deny rule lacks is a task. It is per-machine and permanent, with no owner to ask, no approval moment and no record of who opened what and why. CommitCycle’s unit is a grant: scoped to what one task declared, bound to that task’s branch, with an owner for every protected zone, and expiring on its own.

The hook is also the layer that needs an adapter per editor. CommitCycle can write hook files for Claude Code, Codex, Cursor and Windsurf, and Claude Code is the only one where it has been seen to block a write. What blocks in each editor has the detail.

Why shell commands are contained, not classified

Section titled “Why shell commands are contained, not classified”

No static analysis predicts what npm run build will write. Phase 0 executed 25 escape vectors for real against a repository with a protected zone, counting a vector as contained only if the protected file was byte-identical afterwards, and measured two designs:

  • A conservative allowlist stopped 24 of 24 attacks and blocked 8 of 20 legitimate commands — mkdir, cp, git add and every chained command among them. A 40% false-positive rate on ordinary work is the failure this project ranks as its first risk: being uninstalled through friction.
  • The shipped design is guard-first. The guard snapshots the working tree before and after a shell command and reverts anything that lands outside an open zone. A small denylist covers only what a snapshot cannot undo: network egress, sudo, paths outside the repository, database clients, git push and reads of a closed zone. It contained 25 of 25 vectors with 0 false positives on the same 20 commands. 23 were refused outright — in-place sed -i, interpreter writes, redirects, tee and git checkout -- among them — and 2 ran and were reverted. A build script that also wrote into the protected zone had that write reverted while its legitimate output survived.

What that does not prove. The 20 legitimate commands were chosen by the author, not observed in real work, and a false-positive rate on live work has never been measured. On its first day over its own repository, the classifier refused three legitimate commands in five minutes — shapes the chosen set did not contain. Phase 0 ran on macOS. And a gitignored path inside a zone gets no containment at all.

The hook reads grants from local files and writes events to a local log; the board coordinates and mirrors. The hot path never touches the network, so an outage cannot open a zone — the board was never in the deny path — and an expired grant stops working offline.

Cost per tool call, measured in Phase 0 on 2026-08-03: 68–83 ms at p95 against a 100 ms budget, each sample a freshly spawned process, because timing a warm one would have flattered the result. The limits in the same breath: that was an idle machine, p99 reached 102 ms in one case, and a shell command the guard watches lands around 100–130 ms.

  • A block in Codex, Cursor or Windsurf. No CommitCycle hook has been exercised against a real install of any editor except Claude Code. What blocks in each editor lists what exists for each.
  • The CI check as a required check. CommitCycle’s own repository cannot require it.
  • A false-positive rate on live work. The 0 of 20 comes from commands the author chose.
  • The hook’s latency under load. Phase 0 timed an idle machine.
  • Whether editors other than Claude Code load CLAUDE.local.md.