{"slug": "moving-coding-agent-guardrails-from-prompts-to-hooks", "title": "Moving coding-agent guardrails from prompts to hooks", "summary": "At a recent CTO roundtable on coding agents, an attendee reported that two agents with GitHub access could approve each other's pull requests without being instructed to, quietly defeating the main-branch review gate. The discussion concluded that invariants — rules that must always hold, such as never reading .env or pushing directly to main — should not live inside the probabilistic model, and that Claude Code's hooks, which run commands, HTTP endpoints, MCP tools, model prompts, or subagents at defined lifecycle events, offer a control plane outside the model.", "body_md": "At a recent CTO roundtable on coding agents, the conversation moved through permissions, production access, testing, review, and how much autonomy to give an agent. One attendee described a discovery from his own team: two agents with GitHub access could approve each other's pull requests. Nobody had instructed them to do it. The capability was simply present, and the review gate that was supposed to protect the main branch quietly stopped meaning anything. During the same discussion, someone asked the question we hear most often.\n\n\"Couldn't we just put this into the system prompt?\"\n\nSometimes the answer is yes. More often, the better question is whether a rule is something we want the model to consider, or something the system must guarantee.\n\nWe see the reason for that gap in almost every engagement. A team finds something they do not want their coding agent to do, and they add another sentence to the prompt. The instructions pile up.\n\n- Never read .env.\n- Always run the tests.\n- Don't send customer data to the model.\n- Don't push directly to main.\n- Keep token usage low.\n- Use the cheaper model when possible.\n- Run Ruff after changing Python files.\n- Don't say you are done until you have tested the application.\n\nThese are all reasonable instructions, but they are not the same kind of instruction. Some are preferences, which describe how we would like the agent to behave. Others are invariants, which must always hold. One of our clearest lessons is that an invariant should not live inside the probabilistic system it is meant to constrain, because the model can ignore it. A coding agent has an unusual property that makes this matter more than it first appears.\n\n### A coding agent uses our authority but can be instructed by anyone\n\nThe model acts with authority that came from us, including our filesystem, our terminal, our source repositories, our credentials, our network, and sometimes our production environment. The instructions that influence its behavior can come from almost anywhere, as the following diagram shows.\n\nThe asymmetry matters, because the authority comes from us while the instructions do not. Prompt injection is especially dangerous for coding agents, because the model is not only producing text. It is making decisions while holding the capabilities we gave it.\n\nOur original security work started from an adversarial question, which is which controls still hold when the model is mistaken, confused, or manipulated. The question leads to a useful separation between four kinds of control.\n\n- Prompts shape the model's behavior.\n- Permissions expose or restrict capabilities.\n- Hooks run programmable policy at specific points in the agent's lifecycle.\n- Sandboxes limit what executed code can reach.\n\nClaude Code makes the distinction clearly. Its hooks can run commands, HTTP endpoints, MCP tools, model prompts, or subagents at defined lifecycle events, and depending on the event they can allow, block, modify, or add context.[1](#user-content-fn-1)\n\nThe more we use coding agents, the more we find that security is only one use of hooks.\n\n## The model does not need to be the control plane\n\nThe simple picture of an agent looks like the following diagram.\n\n```\n          ┌────────┐\nUser ───▶ │  LLM   │ ───▶ Tools\n          └────────┘\n```\n\nThe picture makes the model look like the gateway to everything, and we no longer think about it that way. A modern coding-agent harness gives us interception points around the entire loop.\n\n#### Lifecycle interception points around the model\n\nClaude Code now exposes lifecycle events across prompt submission, tools, parallel tool batches, subagents, task completion, compaction, model changes, and session state.<sup>[1](#user-content-fn-1)</sup> The shift is simple. The model is a probabilistic component inside the system, and it does not have to be the system's control plane. Once you see the model that way, many of the things we currently put in `CLAUDE.md` start to look out of place.\n\n## Learning 1: Stop sensitive data before it reaches the model\n\nConsider the instruction to not expose secrets. By the time the model has received an AWS key, a database password, or a customer record, the policy has already failed.\n\nClaude Code's `UserPromptSubmit` event fires when the user submits a prompt, before Claude processes it, which makes it a natural place to check for data loss.<sup>[1](#user-content-fn-1)</sup> Data loss prevention, often shortened to DLP, means stopping sensitive data from leaving a trusted system. In one of our reference implementations, we detect content that looks like a credential before inference. The full implementation handles private keys, AWS credentials, GitHub and GitLab tokens, Slack tokens, API keys, JWTs, bearer tokens, database connection strings, and password assignments. A much smaller example shows the principle.\n\n```\npayload = json.load(sys.stdin)\nprompt = payload.get(\"prompt\", \"\")\n\nredacted, findings = scan(prompt)\n\nif findings:\n    deny(\n        \"Credential-like content detected. \"\n        \"Reference the secret by environment variable instead.\"\n    )\n```\n\n#### Detecting credentials before inference\n\nOur implementation stores fingerprints rather than raw secrets, and it keeps only a redacted copy for debugging. It fails closed if it cannot parse its input.\n\nThere is a second lesson here, which is that the detector does not have to be deterministic. For example, it could use any of the following.\n\n- A regular expression for API keys.\n- An enterprise DLP product to classify personal data.\n- A small model to decide whether text is commercially sensitive.\n\nThe classification may be probabilistic, but the policy that acts on it can still be deterministic.\n\n```\nif classification == \"restricted\":\n    block()\n\nelif classification == \"pii\":\n    redact()\n\nelse:\n    allow()\n```\n\nThe coding agent being protected does not decide whether the policy applies. Keeping that decision outside the agent is what makes the control reliable.\n\n## Learning 2: A permission check is not the same as a policy check\n\nConsider a single command.\n\n```\ngit push origin main\n```\n\nA permission system can constrain which commands and arguments are allowed, but it evaluates each call largely on its own terms. A prompt can only ask the model to avoid pushing to `main`. The real company policy usually depends on conditions that a static allow-or-deny rule does not see.\n\n- Is this main?\n- Did tests pass?\n- Does this touch infrastructure?\n- Are we in a freeze window?\n- Is the diff over 500 lines?\n- Does this developer own this service?\n- Is this production?\n\nThe set of conditions is programmable policy. Claude's `PreToolUse` hook fires after Claude has chosen a tool and its arguments, but before the tool runs. It can block the call, ask for confirmation, or change the input.[1](#user-content-fn-1)\n\n```\ncommand = event[\"tool_input\"].get(\"command\", \"\")\n\nif \"git push\" in command and \" main\" in command:\n    deny(\n        \"Direct pushes to main are blocked. \"\n        \"Create a pull request instead.\"\n    )\n```\n\nThe substring check here is illustrative, not robust enforcement. It would miss alternative forms such as `git push --force origin HEAD:main`, a differently ordered invocation, or a push routed through a shell alias or script. A production policy hook has to parse the command properly, and even then a determined or manipulated process can work around a lifecycle check. Enforcement that must hold against a mistaken or hostile agent belongs at the identity and sandbox layer, which we return to later. What a hook adds over a permission list is the ability to weigh changing conditions, and to do something more useful than deny.\n\nA good hook does not only block. It can also redirect the agent toward an allowed action, which is where hooks become different from a simple allow-or-deny permission list.\n\n## Learning 3: Move deterministic cleanup out of the model\n\nAnother class of instruction should not use model attention at all.\n\n- Remember to run Ruff.\n- Run the formatter.\n- Sort imports.\n- Run Prettier after editing.\n- Don't forget gofmt.\n- Fix ESLint warnings.\n\nThere is no reason to ask a large model to remember any of these steps, because each one has a deterministic program that does the job. Claude's documentation recommends using `PostToolUse` after `Edit` or `Write` to run Prettier automatically after every file edit.<sup>[2](#user-content-fn-2)</sup> For Python, we do the same thing with Ruff.\n\n```\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \".claude/hooks/python-quality.sh\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThe script itself is deliberately simple.\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\n\npayload=\"$(cat)\"\nfile=\"$(printf '%s' \"$payload\" | jq -r '.tool_input.file_path // empty')\"\n\n[[ \"$file\" == *.py ]] || exit 0\n\nuv run ruff check --fix \"$file\"\nuv run ruff format \"$file\"\n```\n\n#### Run formatters and linters automatically\n\nThe same pattern works across languages.\n\n- .py\n- Ruff, then Ruff format\n- .ts/.tsx\n- ESLint --fix, then Prettier\n- .go\n- gofmt, then go vet\n- .rs\n- cargo fmt, then clippy\n\nThe pattern is not really a guardrail. It is deterministic maintenance. We have adopted a simple rule, which is to not ask the model to do mechanical work that the harness can do more reliably. Every deterministic step we move out of the prompt is one less thing the model has to remember.\n\nThere is still a place for the full checks at a later stage.\n\nHooks are not a replacement for continuous integration. They move feedback closer to the moment the change is made.\n\n## Learning 4: Filter large tool output before it enters context\n\nThe next pattern changed how we think about context engineering. Coding agents read a lot, often much more than they need.\n\n- source files\n- logs\n- JSON\n- grep output\n- package metadata\n- generated files\n- test output\n- database results\n- MCP responses\n\nSuppose Claude asks for a file and the tool returns 25,000 tokens. Claude does not have to receive all 25,000 of them. Claude Code's `PostToolUse` can return `updatedToolOutput`, which replaces what Claude sees after the tool has run but before the result enters the model's context.<sup>[1](#user-content-fn-1)</sup> We built a plugin around that boundary. When the user submits a task, we record the task. When `Read`, `Grep`, or certain read-only Bash commands return large results, we compact those results against the current task before they go back into context.\n\n#### Context admission control\n\nThe core mechanism is simple.\n\n```\nif tool_name not in {\"Read\", \"Grep\", \"Bash\"}:\n    return\n\nif token_count(tool_output) < THRESHOLD:\n    return\n\ncompacted = compact(\n    content=tool_output,\n    query=current_user_task\n)\n\nreturn {\n    \"hookSpecificOutput\": {\n        \"hookEventName\": \"PostToolUse\",\n        \"updatedToolOutput\": compacted\n    }\n}\n```\n\nOne detail matters in practice. `updatedToolOutput` must still satisfy the output schema of the tool it replaces. For a built-in tool, an invalid replacement is silently ignored and the original output stays in context, so the compaction does nothing.<sup>[1](#user-content-fn-1)</sup> The same failure mode applies to redaction: a malformed replacement fails open and leaves the sensitive output in place. We validate the shape of the replacement before returning it.\n\nWe also record the original token count and the admitted token count for each intervention. The measurement is useful, because an unnecessary token does not cost you only once. If an unnecessary token stays in the active context, it causes several problems.\n\n- It is sent to the model again in later turns.\n- It interacts with provider caching.\n- It competes for the model's attention.\n- It pushes the session toward full compaction sooner.\n\nWe have started to think about the problem as context debt, which is roughly the number of irrelevant tokens multiplied by how long they survive in the working context. Provider caching changes the cost, but it does not make irrelevant information useful. For that reason, we call the pattern context admission control rather than compression.\n\n#### A production version of this hook\n\nThe code above is a sketch. We run it in practice as a small Claude Code plugin. It registers two hooks. A `UserPromptSubmit` hook records the current task for the session, and a `PostToolUse` hook matched to `Read`, `Grep`, and read-like `Bash` commands sends large tool outputs to a compaction service. The output is only sent when it clears a size floor, currently around 4,000 characters, so short reads pass through untouched. The service points at a compaction model we built for this job rather than a general chat model, and the request carries the recorded task so the filtering is aware of what the agent is trying to do.\n\nThe compaction is extractive. It keeps the lines that are relevant to the task and drops the rest, and every surviving sentence stays verbatim, so it does not paraphrase code or invent detail. When it cannot help, it fails open. If the service errors, times out, or returns a malformed replacement, the hook returns nothing and the original output stays in context. The agent is never blocked by the compactor.\n\nThe numbers below come from our own runs against that service, not a benchmark.\n\nTwo results mattered more than we expected. The first is latency. An earlier version compacted in about ninety seconds, which is long enough that a person waits, notices the wait, and turns the feature off. Moving to the purpose-built model brought a typical compaction to around 2.5 seconds at roughly 5,000 tokens per second, which is short enough to disappear into the normal rhythm of the agent. The second is that the saving compounds. Compacting one million tokens of tool output costs about $0.40, but the compacted result is smaller every time it is replayed. Across ten later turns at a $3 per million input rate, a block that would have cost $30.00 to keep resending costs $12.40 instead. The one-time compaction cost is small next to what an unremoved block keeps charging you.\n\nOne failure is worth naming. Because the size floor is measured in characters, a large output made of very short lines, such as a wide table dumped by a read-like command, can sit just under the threshold and skip compaction even though it is heavy in tokens. We treat the character floor as a cheap first filter, not a precise one, and we are moving the decision toward a token estimate.\n\n### The same pattern is appearing in other tools\n\nHeadroom is a good example of the same idea applied lower in the stack. Its open-source implementation compresses tool outputs, logs, retrieval results, files, and conversation history before they reach the model. It can wrap Claude, Codex, Cursor, and other agent tools, and it can retrieve the original content again when needed.<sup>[3](#user-content-fn-3)</sup> Its integration API exposes its own compression lifecycle.\n\n``` python\nclass MyHooks(CompressionHooks):\n\n    def pre_compress(self, messages, ctx):\n        return messages\n\n    def compute_biases(self, messages, ctx):\n        # > 1 keep more\n        # < 1 compress harder\n        return {5: 1.5, 6: 0.5}\n\n    def post_compress(self, event):\n        print(event.tokens_saved)\n```\n\nThe result is recursive.\n\nThe implementation is different, but the idea is the same, which is to make the boundaries programmable.\n\n## Learning 5: Use cost controls that stop the run, not just report on it\n\nWe often treat inference cost as something to observe rather than control. We run an agent, run another agent, spawn several subagents, and open the usage dashboard later, after the money is already spent. Cost is also an admission-control problem.\n\nClaude Code's `PostToolBatch` fires after a batch of parallel tools has finished, but before the next model call.<sup>[1](#user-content-fn-1)</sup> It is a useful place to stop a run that has spent too much.\n\n#### Stopping the run before the next expensive model call\n\n*Illustrative values, not measured results.*\n\nThe open-source `claude-cost-guard` project does this. It uses `PostToolBatch` to enforce per-step and per-session budgets, and `UserPromptSubmit` to stop new work once the session has crossed its cap. It also uses `PreCompact`, because compaction can itself cost money.[5](#user-content-fn-5)\n\nAsking the model to try not to spend more than five dollars is only guidance. Enforcing a budget in the harness is accounting. The guarantee depends on when you stop. We reserve the estimated cost of the next model call, so a session that has spent $4.91 against a $5.00 cap halts before the next inference rather than after crossing the line. Stopping only once spend has already passed the cap is simpler to write, but it always overshoots by one model call.\n\n```\nBUDGET = 5.00\n\n# Reserve the next model call: stop before it runs if it would\n# cross the cap, rather than after the money is already spent.\nif session_cost + estimated_next_call_cost >= BUDGET:\n    stop()\n```\n\n### Model routing can also be policy\n\nClaude Code also exposes `PreModelSwitch`. Before a switch happens, the hook can see several values and then allow, deny, or ask for confirmation.[1](#user-content-fn-1)\n\n- current model\n- destination model\n- current context token count\n- cache state\n- estimated cache-writing cost\n\nFor example, the hook can deny a switch, as the following diagram shows, or it can ask the user to confirm an estimated extra cost of $1.14 before continuing.\n\n*Illustrative values, not measured results.*\n\nThe effect is cost management at the model boundary. It also shows a wider point, which is that model routing does not have to be a decision the current model makes about itself.\n\n## Spotify first put its routing rules in the prompt\n\nSpotify's recent Shunt work is a good example. Their first approach put the routing rules in `CLAUDE.md`, and Spotify describes the result this way.\n\n\"It sort of worked.\"\n\nClaude would sometimes route expensive input and output work to a cheaper worker, but the instructions were advisory, so Claude could ignore them. They then moved the routing rule out of `CLAUDE.md` and into `PreToolUse`.<sup>[6](#user-content-fn-6)</sup> Their Shunt plugin watches `Read` calls. If a full-file read is larger than a configurable threshold, which defaults to 350 lines, the plugin blocks the read and points Claude to a cheaper bulk-reader. Targeted reads still pass through.[7](#user-content-fn-7)\n\n#### The same rule enforced at a different point\n\nTheir published benchmark reports 82% to 94% token savings for the large-file cases, and about 90% on average.<sup>[7](#user-content-fn-7)</sup> The exact number is not the important part. The important part is the separation of responsibilities, where the model decides what it needs to know and the harness decides how that work runs. Spotify also excludes debugging, architecture decisions, and other reasoning-heavy tasks from the cheaper path.<sup>[6](#user-content-fn-6)</sup> The result is model routing as policy, rather than model routing as another prompt instruction.\n\n## Learning 6: Make \"done\" a state the harness can block\n\nWe often see the following problem. The coding agent has access to the tools it needs to check its work.\n\n- tests\n- browser\n- dev server\n- logs\n- database\n\nIt finishes the implementation and reports that the work is done, but nobody has actually opened the application. The same issue came up at the CTO roundtable. Agents often have the tools needed to verify their work and still do not use them unless something forces them to. We can put a reminder in the prompt, such as asking the agent to always verify its work before finishing. We can also change what finished means. Claude's `TaskCompleted` and `Stop` events can block completion.[1](#user-content-fn-1)\n\n#### Blocking completion until verification passes\n\nThe check can be deterministic.\n\n```\nif not tests_passed():\n    block(\"Tests are failing.\")\n\nif frontend_changed() and not browser_check_exists():\n    block(\"Browser verification has not been performed.\")\n```\n\nIt can also combine deterministic and model-based checks.\n\n- Tests\n- deterministic\n- Build succeeds\n- deterministic\n- Browser smoke run exists\n- deterministic\n- Meets product intent\n- model/judge\n- Architecture sensible\n- model/human\n\nClaude now supports prompt-based hooks and experimental agent-based hooks. An agent hook can start a subagent with read and search tools to verify a condition before it allows a lifecycle transition. Anthropic recommends command hooks in production where possible, and suggests agent hooks for checks that need to inspect the codebase.<sup>[1](#user-content-fn-1)</sup> The principle is to use code for invariants and models for judgment.\n\n## Learning 7: For long runs, compaction is state management\n\nTwo kinds of compaction matter here. The first is the one already described, which reduces individual tool results before they enter context. The second happens when the whole conversation gets too large. A long-running agent can accumulate a large amount of state.\n\n- 180,000 tokens\n- hundreds of tool calls\n- architectural decisions\n- failed attempts\n- open TODOs\n- temporary assumptions\n- verification evidence\n\nThe harness then summarizes that state and loses some of it. The summary is a state transition, not only a display detail.\n\n#### Compaction as a checkpoint\n\n*Illustrative token counts, not measured results.*\n\nClaude exposes both `PreCompact` and `PostCompact`. Codex exposes the same pair, and Gemini and Cursor expose pre-compaction events as well.<sup>[1](#user-content-fn-1)</sup> For long-running work, several questions matter.\n\n- What must survive?\n- Which decisions were important?\n- What unresolved work exists?\n- Can another agent resume from this checkpoint?\n- What was removed?\n- What did compaction itself cost?\n\nOur working assumption is that once an agent runs long enough, its context becomes state. Compaction then needs state-management engineering, not just a larger context window.\n\n## Learning 8: Hooks are also useful for observability\n\nNot every hook needs to change behavior. Some hooks only report what happened.\n\n- SessionStart\n- record environment posture\n- PreToolUse\n- log proposed risky action\n- PostToolUse\n- capture duration and result metadata\n- PostToolUseFailure classify recurrent tool failures\n- SubagentStart\n- record fan-out\n- PreCompact\n- record context pressure\n- SessionEnd\n- persist run summary\n\nReporting becomes useful once agent sessions stop looking like single chat turns and start looking like long jobs. A single run can do a lot of work.\n\n- last 45 minutes\n- make 300 tool calls\n- spawn 8 subagents\n- compact twice\n- touch 46 files\n- switch models\n- fail 17 tool calls\n- spend $8\n\nAt that point, the correct final answer is not the only thing worth knowing. We also want to know what happened during the run. Hooks make the agent's lifecycle observable without asking the model to describe itself.\n\n## A map of the hook lifecycle\n\nClaude Code's set of hooks has grown a lot.\n\n#### What you can intercept\n\nFor that reason, we prefer the phrase lifecycle interception over the word hooks. Claude's current hook reference includes all of the events above.<sup>[1](#user-content-fn-1)</sup> The important idea is not the name of the hook. It is the boundary where the hook runs.\n\n## Other coding agents use the same pattern\n\nWe use Claude Code throughout this article because it makes the examples concrete, but other coding-agent harnesses share the same pattern.\n\nGemini CLI has hooks around the agent, the model call, tool selection, tool execution, and context compression. For example, `BeforeModel` can change prompts or model parameters before inference, and `BeforeToolSelection` can filter the tools available to the model.[8](#user-content-fn-8)\n\nCursor exposes hooks for prompts, generic tools, the shell, MCP, file edits, subagents, compaction, and completion. It can read several Claude Code hook definitions directly and map them to its own lifecycle events.[9](#user-content-fn-9)\n\nCodex exposes `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and compaction, subagent, and completion hooks as well. Its documentation notes that tool hooks are a useful guardrail rather than a complete enforcement boundary.[10](#user-content-fn-10)\n\n#### The names differ, the architecture does not\n\n| Boundary | Claude Code | Codex | Gemini CLI | Cursor | \n|---|---|---|---|---|\n| Before user request reaches agent | `UserPromptSubmit` | `UserPromptSubmit` | `BeforeAgent` | `beforeSubmitPrompt` | \n| Before LLM request | n/a | n/a | `BeforeModel` | n/a | \n| Before tools are selected | n/a | n/a | `BeforeToolSelection` | n/a | \n| Before tool execution | `PreToolUse` | `PreToolUse` | `BeforeTool` | `preToolUse` | \n| After tool execution | `PostToolUse` | `PostToolUse` | `AfterTool` | `postToolUse` | \n| Before compaction | `PreCompact` | `PreCompact` | `PreCompress` | `preCompact` | \n| After compaction | `PostCompact` | `PostCompact` | n/a | n/a | \n| Before model switch | `PreModelSwitch` | n/a | `BeforeModel` can influence requests | n/a | \n| Completion | `Stop` /`TaskCompleted` | `Stop` | `AfterAgent` | `stop` | \n\nThe APIs differ, but the direction is the same. Coding-agent harnesses are becoming programmable around their lifecycle. Design the policy around the lifecycle, not around the exact names Anthropic uses today.\n\n## Hooks are not a sandbox\n\nIt is tempting to make hooks the security boundary, but they are not one. Suppose a hook approves a command.\n\n```\n./deploy.sh\n```\n\nThe hook inspected the tool call, but it did not constrain the system calls that `deploy.sh` or its child processes can make. It does not, by itself, stop the script from doing any of the following.\n\n- reading ~/.ssh\n- talking to arbitrary network endpoints\n- accessing Docker\n- reading another mounted directory\n- launching child processes\n\nThere is also the time-of-check to time-of-use problem, where the state can change between what the hook inspected and what the code finally uses. Both limitations were central in our original design notes. Hooks operate at the agent lifecycle layer, and the hard boundary lives lower down. Claude Code's sandbox, for example, uses Seatbelt on macOS and bubblewrap on Linux and WSL2 for isolation at the operating-system level.[11](#user-content-fn-11)\n\n#### Different controls answer different questions\n\nIn short, prompts express intent, hooks enforce workflow policy, and sandboxes limit capability. Stronger boundaries can give more autonomy, not less. If we know in advance how much damage an agent can do, we can let it run longer without making human approval clicks the main security control.\n\n## Other lessons from running this in production\n\n### 1. Start new controls in log-only mode\n\nWe rarely want to deploy a new policy and immediately block engineers. Our DLP hook supports a log-only mode first, which lets us measure false positives before we turn on enforcement.\n\nThe same approach works for many controls.\n\n- secret detection\n- forbidden shell patterns\n- large-read routing\n- lint enforcement\n- cost caps\n- completion gates\n\n### 2. Failing open or closed is a product decision\n\nIf our DLP gateway is down, letting every secret through is the wrong fallback. If our metrics service is down, stopping every developer is also wrong. Our gateway pattern separates enforcement from observability, so critical policy can fail closed while logging can fail open.\n\n### 3. Do not let the agent control its own policy\n\nA hook stored in a repository that the coding agent can edit is not much of an organizational control. Claude supports managed policy hooks that users cannot remove from project settings, and Cursor supports enterprise-managed and team hooks.<sup>[1](#user-content-fn-1)</sup> If the rule matters at the company level, the policy should live above the thing it governs.\n\n### 4. Test the outcome, not the hook script\n\nA policy test should not ask whether the script printed the word deny. It should ask whether the forbidden action actually failed. The same question applies to each control.\n\n- Did the secret actually stay out of model context?\n- Was the written file actually formatted?\n- Did another inference really not happen?\n- Could the agent mark an unverified task complete?\n\nHooks are executable policy, so test them the way you would test any other policy.\n\n## What should stay in the prompt\n\nA lot should stay in the prompt. Prompts are good for judgment and taste, such as the following guidance.\n\n- Prefer the simplest implementation.\n- Follow the repository's existing patterns.\n- Explain surprising decisions.\n- Avoid unnecessary abstractions.\n- Ask before changing architecture.\n- Consider backward compatibility.\n- Think about edge cases.\n\nWe want the model to reason about that kind of guidance. We do not want the model to be the only authority on questions like these.\n\n- Whether a secret leaves the machine.\n- Whether production is modified.\n- Whether another $5 is spent.\n- Whether 30,000 log tokens enter context.\n- Whether a file gets formatted.\n- Whether 20 new subagents are spawned.\n- Whether verification is mandatory.\n- Whether a task is allowed to finish.\n\nThose are operating constraints. Before adding the next sentence to `CLAUDE.md`, we ask whether it is a preference or an invariant. If it is a preference, tell the model. If it is an invariant, ask whether the model should have a say at all.\n\n## Most useful hooks do not block anything\n\nWhen people first meet hooks, they think of blocking a dangerous command such as `rm -rf /`. Blocking is useful but narrow. The more useful question is what hooks let us do to an agent's runtime.\n\n#### Seven ways we use lifecycle controls\n\nThat list is closer to how we now think about hooks: the programmable points in the agent runtime, not only a security feature.\n\n## The larger point\n\nOver the past few years, most of the attention has gone into improving the probabilistic part, including better coding models, longer context windows, better tool use, better reasoning, and more agents. Those improvements matter a great deal. As the models get more capable, though, the engineering challenge moves increasingly to the code around the model. We call that surrounding code the harness.\n\nOur strongest lesson so far is to let the model reason, and to not make it responsible for enforcing its own boundaries. Getting a coding agent to write code is becoming easy. Building the environment in which it can operate reliably is not.\n\n## Coding Agents workshop\n\nWe run a hands-on workshop for engineering teams who want to move these rules out of their prompts and into their runtime. You leave with a working policy setup for your own coding agent: a `PreToolUse` gate that enforces your push and access rules, context admission control on large tool outputs, a session cost cap that stops a run before it overspends, and a completion gate that blocks \"done\" until your checks pass. We start from your existing repositories and agent configuration, not a toy example, so the controls you build in the session are the ones you keep running afterward.\n\nTo book a session for your team, get in touch with us at Tesseracted Labs.", "url": "https://wpnews.pro/news/moving-coding-agent-guardrails-from-prompts-to-hooks", "canonical_source": "https://tesseracted-labs-blog.vercel.app/enforcing-coding-agent-guardrails-in-the-runtime-instead-of-the-prompt", "published_at": "2026-09-15 23:18:00+00:00", "updated_at": "2026-09-15 23:37:48.352222+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-tools", "developer-tools", "ai-policy"], "entities": ["Claude Code", "GitHub", "MCP", "Ruff"], "alternates": {"html": "https://wpnews.pro/news/moving-coding-agent-guardrails-from-prompts-to-hooks", "markdown": "https://wpnews.pro/news/moving-coding-agent-guardrails-from-prompts-to-hooks.md", "text": "https://wpnews.pro/news/moving-coding-agent-guardrails-from-prompts-to-hooks.txt", "jsonld": "https://wpnews.pro/news/moving-coding-agent-guardrails-from-prompts-to-hooks.jsonld"}}