The other 80 percent

Most people run Claude Code as a faster autocomplete. Open a terminal, describe the task, review the diff, accept it. That loop works, and it is a fraction of what the tool actually does. Underneath the chat interface is a full control layer for governing how an agent behaves: what it is allowed to touch, what it has to prove before it can claim a task is finished, and what happens when its own context runs out partway through a job. Almost nobody uses that layer, because almost nobody needs to until the job stops being a single session and starts being a build.

This is what that layer looks like once you do need it, built and tested against a real 72-hour autonomous build rather than a demo.

The setup

Three Claude Code agents. Two implementing, one supervising. Seventy two continuous hours, minimal human involvement. The target was a production application, unlaunched, and it stays unnamed here on purpose: this is not a case study of a product, it is a record of the control surface that made unattended building survivable.

The green light that meant nothing

Eighty tables. A hundred and forty one row-level security policies. A CI assertion confirming every table had a policy attached. Green, on every commit, for days.

Then one of the agents wrote a test that did the obvious thing nobody had done yet: logged in as a real user and tried to read a table. Permission denied. Not by a policy. Before any policy was consulted. Every one of those eighty tables was missing its table-level grant, so Postgres rejected the query at the door, and all hundred and forty one policies had never once been evaluated. They were decorative. The CI check had been answering a true and completely irrelevant question the whole time: does every table have a policy? Yes. Is any policy reachable? Nobody had asked.

That is the thesis of everything below. The risk in an autonomous build is not code that breaks. Broken code is loud. The risk is code that passes convincingly while enforcing nothing, because a check that only confirms a policy exists will pass forever on a database that grants no one access to anything.

Tier one: advisory

CLAUDE.md, or whatever equivalent instruction file a project uses, is read every session and followed most of the time. Most of the time is doing a lot of work in that sentence. Anthropic's own documentation is candid that instruction-following is probabilistic, with no guarantee of compliance, particularly where a rule is vague or two rules conflict. Three drift modes turn up often enough to name: a rule gets ignored mid-execution because something else felt more urgent in the moment, a rule gets forgotten as the context window fills and compacts, and a rule gets skipped because the model judged, in that instance, that it did not apply. One documented case had an agent report that it could read the rule and recite the rule back verbatim, and then simply not follow it on the next action.

Over seventy two hours, context fills and compacts repeatedly. Drift is not a risk to mitigate. It is a certainty to design around.

Skills sit in this same tier, and they are worth having. A skill is a folder with a SKILL.md file that Claude loads on its own initiative when the task description matches, useful for procedural house knowledge you do not want to keep re-explaining without permanently burning context on it every session. But a skill is still teaching, not enforcement. It changes what the model is likely to do. It does not change what the model is able to do. The instruction file itself deserves its own closer look, since it is the piece most teams already have and lean on too hard: why your CLAUDE.md is not a control.

Tier two: hooks

This is the tier that carries the argument. A hook is a shell command Claude Code runs at a fixed point in its own lifecycle, receiving the event as JSON on stdin and answering with an exit code. There are around thirty lifecycle events available. The one that matters most for control is PreToolUse, which fires before any tool call executes. Exit 0 and the call proceeds. Exit 2 and the call is blocked, with your stderr message handed straight back to the agent as the reason. None of this sits inside the model's own judgement. It cannot be argued with, skipped under pressure, or forgotten when the context compacts, because it never lived in context in the first place.

Three gotchas catch people the first time they wire one up.

  • Exit 1 does nothing useful here. It is the conventional Unix failure code, so it is the instinctive choice, and Claude Code treats it as a non-blocking error: the action proceeds regardless. Anything security-critical has to exit 2, deliberately.
  • A JSON decision object only parses on exit 0. Print a structured block decision and then exit 2, and the JSON is discarded. The agent only ever sees stderr, so that is where the reason has to live.
  • Blocking behaviour is not uniform across events. PreToolUse denies the call outright, so it never runs. A Stop hook exiting 2 prevents the turn from ending and hands the agent the reason, which makes it a strong suggestion the agent can still reason its way past, not an absolute wall.

The consequence for how you build with this is straightforward. Anything that must never happen, under any circumstance, goes in PreToolUse. False completion claims, where an agent announces a task is done, get caught at the commit boundary instead of at the moment the agent says it is finished, which matters because "finished" is exactly the claim you cannot take at face value. SessionStart is worth using too, to re-inject build state, task contracts and acceptance criteria after every compaction, because internal memory is not durable across a 72-hour run and external memory has to carry what the model will otherwise drop. Skills teach. Hooks enforce. Keep those two jobs separate.

#!/bin/bash
input=$(cat)
path=$(echo "$input" | jq -r '.tool_input.file_path // empty')

if [[ "$path" == *"/.env"* ]]; then
  echo "Blocked: .env files are off limits to automated edits." >&2
  exit 2
fi

exit 0

Tier three: gates

CI blocking a merge is familiar to anyone who has shipped software with a team. What is less familiar is the discipline a control needs before it is allowed to count as one: plant a defect, confirm the check actually fails, revert the defect, confirm green again. Twenty four static checks in this build were each proven to fail on a deliberate break before they were trusted to pass on real code.

Two examples earned that discipline back. A file-matching utility, written to replace a runtime API that was not available in the build environment, matched nothing at all. A pattern-translation bug meant the glob it constructed could never resolve to a real path, so three separate verification scripts were quietly scanning an empty set and passing vacuously on every run. It was only caught because the negative test, the one designed specifically to fail, failed to fail. One level up, an agent-written assertion meant to catch the missing-grants problem from the story above had its own first version filter on a column that always held the same value, so it returned zero rows and passed cleanly against a database that was completely broken. An assertion that cannot fail is worse than no assertion, because it converts an unknown into a false certainty, and a false certainty is the one failure mode nobody is looking for.

Running three agents without them destroying each other

Split by write boundary, not by feature. Feature splits sound natural and produce constant interface renegotiation instead, because two agents each building half of the same user-facing flow end up needing to agree on the same contract in real time, which is exactly the coordination a multi-agent build cannot rely on. One agent owned the data layer and transactional logic. Another owned the application surface. The contract between them, schema, error codes, function signatures, page structure, design tokens, was frozen before anything parallelised, then enforced with hooks that deny writes to the paths that contract lives in.

Isolated working copies stop two agents from overwriting the same file. They do nothing about two agents making incompatible assumptions about a contract that was never pinned down, and over three days of repeated compaction, incompatible assumptions are not a risk, they are a schedule. The supervising agent in this setup was deliberately adversarial: a worker claiming a task complete is making a claim, not presenting evidence, and the only way to check a claim like that is to run the software, never to read the diff and nod along. Coordination between all three agents ran through the filesystem rather than through shared context, because files survive compaction and context does not.

What the controls actually caught

Every defect below sat inside code that read as entirely sensible on a diff. That is the point of this list.

  • A privilege escalation path. A function approving organisation ownership claims took the acting user as a parameter and never checked that the caller was that user, while running with definer privileges that bypass row-level security by definition, meaning the table policies protecting that data offered no protection at all. Any authenticated user could have approved their own claim over any organisation in the system.
  • A guarantee that was not a guarantee, three separate times. A business rule capping how many recipients could receive a given record was implemented correctly inside the function that allocated them, while the underlying column stayed directly writable and the cap itself appeared in no constraint, trigger or policy anywhere in the schema. The rule this produced: a guarantee enforced only inside a function is not a guarantee, while the column underneath it stays writable by anything else that can reach it.
  • A generated migration that was quietly wrong. Tooling used to recreate a function silently reset its permissions in the process, captured only one of two required changes, and corrupted a single character inside a comment. The tool's own documentation says plainly that it is not foolproof, and the failure showed up exactly where that warning said it could.

Not one of these three was found by reading code. Every one was found by something executing, which is the same discipline the gates section above depends on: a control that has only ever passed is not a control.

What this changes about building products

For roughly twenty five years the binding constraint on a new product was the cost of producing the software, which is where the minimum viable product came from as an idea. Production is no longer the expensive part. The constraint has moved to specification quality and architectural rigour, because those are now the only inputs the output actually depends on. An agent faithfully builds what you thought through, and just as faithfully builds what you did not, fluently, at a speed where nobody notices the gap until something reads a table it should never have been able to reach. Vague architecture used to produce slow projects. It now produces fast, plausible, comprehensively wrong ones, on schedule.

There is an optimistic reading of this too. When production is cheap, minimum viable product no longer has to mean minimum viable engineering. The old justification for shipping something insecure and hardening it after traction arrived was economic: engineering rigour up front was expensive, and the market rewarded speed over correctness. That trade only made sense while producing the correct version actually cost more than producing the fast one. The economics changed.

Four rules worth keeping

Structural impossibility beats policy correctness. Sensitive data belongs behind a single access function rather than behind a column merely protected by a rule, because a leak should not be expressible in the first place rather than simply prohibited after the fact. Default deny, then grant explicitly: a new function should be unreachable until it is registered, and unreachable is the correct failure mode for anything not yet accounted for. If a test cannot be made to fail on a real defect, the control it claims to provide does not exist, so delete the claim or build the proof, and do not leave the claim standing on its own. Anything a build's own documentation asserts has to be executable, because a workflow that describes tests it does not actually run is a claim, not a gate.

Start with one hook

Write a PreToolUse hook this week. Ten lines is enough, blocking one specific thing the agent should never be allowed to touch. Run it, then watch it deny the call. The moment a model is handed a refusal it cannot argue its way past is the moment the ceiling on what you can safely delegate to it moves a very long way.

Lead Source runs on infrastructure built the same way: hooks and gates governing what an agent can touch before it ever ships. If you want the source of every lead your marketing produces held to the same standard, see how Lead Source tracks it.