Skip to content

8 min read

Lifecycle position decides control versus detection

On this page

At 14:07 on a Tuesday the platform channel received exactly the message it had been built to produce:

text
PROD_PATH_VIOLATION session=b1f2 tool=Write
  path=infra/prod/us-east-1/alb.tf
  ts=2025-03-11T14:07:22.418Z  rule=INFRA-9

The guard was thirty lines of Python, it had been reviewed by two people, and in the four weeks since it shipped it had never missed a violation and never produced a false one. The problem was the timestamp on the line above it in the file-system audit log, 14:07:22.104, which is when the agent's write to alb.tf completed. The hook had been registered on PostToolUse, so it ran 314 milliseconds after the bytes were on disk. By the time a human read the alert the agent had already re-read the file, generated a plan against it, and moved on to the next step of its task.

Rolling that back took 22 minutes, and only three of those were the revert. The other nineteen went on working out which of the two changes in the modified file were the ones somebody had asked for, because the agent had folded an intended health-check tweak and an unintended target-group rename into a single write. Across that quarter the guard fired nine times, caught nine of nine violations, and stopped none of them.

What each boundary can see, and what it can still change

A single agent turn passes through a series of points where the harness can hand control to a program. Each one differs in two respects that pull against each other: how much it knows about what is going to happen, and how much of it can still be stopped.

Sequence: User → Agent loop (prompt); Agent loop → Hooks (UserPromptSubmit); Hooks → Agent loop (allow / block / add context); Agent loop → Agent loop (model produces tool call); Agent loop → Hooks (PreToolUse (name + args)); Hooks → Agent loop (allow / deny / rewrite); Agent loop → Tool (execute); Tool → Agent loop (result + side effects); Agent loop → Hooks (PostToolUse (args + result)); Hooks → Agent loop (allow / feed error back); Agent loop → Hooks (Stop (turn would end)); Hooks → Agent loop (end / force continue).

The gap between PreToolUse and PostToolUse is where the entire difference lives. Before the call, the hook holds a proposal: a tool name and a JSON object of arguments that describe an operation that has not occurred. It can refuse, and nothing in the world changes. After the call, it holds a fact plus the tool's response, and refusing means printing an objection about something that has already happened.

BoundarySeesCan prevent the effectTypical honest use
UserPromptSubmitraw prompt text, session idthe whole turninject context, block secrets in prompts
PreToolUsetool name, arguments as proposedyeswrite guards, command allowlists, cost caps
PostToolUsearguments plus tool resultnoformatting, linting, audit of outcome
Stopfull transcript statenothing already doneforce a verification step before ending
SubagentStopsubagent's final statenothing already donecheck a delegated result before it returns
SessionEndsession summarynothingarchive, cleanup, report

The asymmetry is not an accident of any particular harness. It is the ordinary relationship between knowledge and power in any interception point, and it is the same one you already know from database triggers: a BEFORE trigger can veto or alter the row and does not know what the statement will finally affect, while an AFTER trigger knows exactly what changed and can only raise an error that rolls the transaction back — and outside a transaction, that rollback does not exist. An agent writing a file is not inside a transaction. Neither is one sending an email, creating a cloud resource, or posting to a webhook.

The same rule, one position earlier

Moving the guard is mostly mechanical. It becomes a PreToolUse matcher on the writing tools, and the predicate reads the proposed path out of tool_input instead of reading the outcome out of tool_response.

python
#!/usr/bin/env python3
import json, os, sys

event = json.load(sys.stdin)
tool = event["tool_name"]
args = event.get("tool_input", {})

PATH_FIELD = {"Write": "file_path", "Edit": "file_path", "NotebookEdit": "notebook_path"}
if tool not in PATH_FIELD:
    sys.exit(0)

root = os.path.realpath(os.environ["CLAUDE_PROJECT_DIR"])
target = os.path.realpath(os.path.join(root, args[PATH_FIELD[tool]]))
guarded = os.path.join(root, "infra", "prod")

if target == guarded or target.startswith(guarded + os.sep):
    print(
        f"Blocked by INFRA-9: {os.path.relpath(target, root)} is under infra/prod/, "
        "which is changed only through a reviewed PR. Write the change to "
        "infra/staging/ or open a PR with `infra-pr new`.",
        file=sys.stderr,
    )
    sys.exit(2)
sys.exit(0)

The realpath call is the part that the PostToolUse version never needed. After the fact, the tool response tells you which file was actually written; before the fact, all you have is a string the model produced, and that string can be relative, can contain .., and can point at a symlink that resolves into the guarded tree. Resolving it is the cost of running early, and it is a real cost, because a hook that reasons about intent rather than about the resolved effect will be wrong in the cases that matter most. The team's first PreToolUse version compared the raw string against the prefix infra/prod/ and was defeated within a day by a path of ./infra/../infra/prod/us-east-1/alb.tf, which the agent produced for no reason more sinister than having built it by string concatenation from two variables.

Resolution is not the only thing the early boundary has to work harder for. A PostToolUse hook knows whether the write succeeded; the PreToolUse version has to treat a proposal as though it will succeed, which means it blocks operations that would have failed anyway on a permissions error or a missing directory. That produces a small stream of rejections for calls that were never going to do damage, and it is the right trade, because the alternative is deciding in the one place where the decision no longer matters. The team measured it: over six weeks the PreToolUse guard blocked 23 writes, of which 4 would have failed on their own for unrelated reasons. Four unnecessary rejections against nineteen prevented incidents is not a ratio anyone argued about.

That is the trade in general form: earlier boundaries have authority over an operation they only partly understand, later boundaries understand an operation they no longer have authority over. Every hook you place is a choice about which of those two you would rather be short of, and the choice is decided by whether the effect is reversible. A write inside a git working tree is cheap to undo and detection can genuinely be enough. A DELETE against a production table, an outbound email, a payment, a terraform apply against us-east-1 — for those the later boundary is an alarm attached to a thing you cannot un-ring.

Detection is a product, just not the one that was ordered

The nine-for-nine record of the original guard is worth taking seriously rather than treating as a failure. PostToolUse is the correct home for a large class of enforcement, and it is where the value of seeing the result actually pays. Running ruff format on every file the agent edits belongs there, because formatting an unwritten file is meaningless. Running the type checker after an edit and feeding the errors back to the model belongs there, because the errors are the point. Recording what a tool actually did, as opposed to what it was asked to do, can only be done there.

PostToolUse also retains one real power, which is the ability to stop the run from continuing. Exiting 2 from a post-call hook does not undo the write, and it does put the objection into the model's context and prevents the agent from proceeding as though the step had succeeded. That contains the blast radius — the span of things one bad step is allowed to touch before anything stops the run — in the specific sense that step seventeen of a multi-step change never happens, which is often the difference between one bad file and a coherent, deployed, bad change. The team's incident is an example: the write itself was recoverable in three minutes, and the nineteen expensive minutes came from everything the agent did afterwards on top of it. Had the same hook halted the run at 14:07:22.418, the cost would have been a revert and an annoyed engineer.

There is a third position worth knowing about for the same reason, which is Stop. A hook there runs when the agent believes the turn is over, sees the state the run finished in, and can refuse to let it end, sending the model back to do something first. Nothing it does prevents any individual effect, and it is the only boundary that can enforce a property of the whole turn rather than of a single call — that every file the run touched is covered by a test, for instance, or that the change request was updated before the session closed. Verifier-first design puts most of its weight there, and the reason it works at Stop and not at PreToolUse is that the property being checked does not exist yet when any one tool call is proposed.

So the honest framing of the original design is that it built a containment-and-audit mechanism and shipped it under the name of a write guard. The alert text said PROD_PATH_VIOLATION, the runbook said "the agent cannot write to prod", and the gap between those two sentences was invisible until an audit compared timestamps. Naming a detector after the thing it detects is how a team ends up believing an invariant holds when what holds is a notification.

Two hooks at the same boundary do not automatically compose

The other half of "ordering" is what happens when more than one program is attached at the same point. Suppose a cost-cap hook and the path guard both match Write. Whether the guard sees the arguments the model produced, or the arguments as amended by the cost hook, is a property of the harness rather than of your code, and the two possible answers lead to different bugs.

Claude Code runs all matching hooks for an event in parallel and deduplicates them, so each one receives the same original event payload, and their decisions are combined rather than chained: any hook that blocks, blocks. A hand-rolled interceptor chain around a tool client usually does the opposite, passing each interceptor's output into the next, which means the second interceptor sees a mutated request. Code written for one model and deployed under the other fails quietly. A guard written assuming it sees the final arguments, running in a parallel harness, will validate a payload that a sibling hook has since rewritten; a guard written assuming it sees the original arguments, running in a chain, will validate something the model never proposed.

The way to find out which one you have is not to read the harness documentation and believe it. It is to attach two trivial hooks at the same boundary, have the first rewrite a marker field and the second print what it received, and run one tool call:

bash
$ claude -p 'write the string hello to /tmp/probe.txt' --debug 2>&1 \
  | grep -E 'hook|probe-marker'
[hook] PreToolUse:Write  rewrite-marker  exit=0  duration=6ms
[hook] PreToolUse:Write  echo-marker     exit=0  duration=4ms
echo-marker saw: {"file_path": "/tmp/probe.txt", "content": "hello"}

No marker in the payload the second hook received, and durations that overlap rather than sum, which together say the hooks ran against the same original event. Six milliseconds is also the first latency number in this topic that anyone measured, and multiplying it by thirty is the arithmetic chapter 9 has to answer for.

Having moved the guard to a point where it can still say no, the question becomes what "no" should consist of. The path hook above refuses and explains. A tempting alternative, and the one that produces the most dangerous class of hook bug, is to keep the call alive by fixing the argument that made it unsafe.

Hooks and deterministic interception — read the current version at https://daviddominguez.dev/learn/en/hooks-and-deterministic-interception/lifecycle-position-decides-control-versus-detection

Back to top