AI-Assisted Engineering

Guardrail Hooks to Stop an AI Agent Before It Breaks Things

An AI agent runs real tools, and now and then it runs the wrong one. Here is how to catch a destructive action at the execution layer, not after.

An AI coding agent does not just suggest text. It runs tools: it executes shell commands, writes files, commits, and sometimes deploys. Most of the time it is competent. Now and then it tries something you would never have approved, and by the time you read the transcript the command has already run. A prompt that says "be careful" does not stop this, because the model can ignore it. What stops it is a hook: code on the agent's tool path that inspects each call and can refuse it before it runs. This post is about how those hooks work, where to put them, and when a prompt is enough on its own.

The failure the hook is for

The agents worth deploying are the ones you let touch real systems. That is also what makes them dangerous. Give a model a shell and it will occasionally reach for a command that is fine in the abstract and catastrophic in context:

  • A destructive command run against the wrong target. rm -rf on a path that turned out to be a symlink, a DROP on a database that was not the throwaway one.
  • A secret committed. The agent stages everything, and everything includes the .env file you meant to keep local.
  • A deploy that should have waited. The change looked done to the model, so it pushed it straight to production.
  • A premature "done." The agent declares the task complete while a test is still red or half the checklist is unchecked.

None of these come from a bad model. They come from a competent model acting on an incomplete picture, at a speed that leaves no room for a human to intervene between the decision and the action. Reviewing after the fact is too late. The command has run.

Why a prompt is not the control

The obvious fix is to write the rule into the system prompt. "Never run destructive commands. Never commit secrets. Never deploy without approval." This helps, and you should still do it, but it is not a control. It is a suggestion with good odds.

A prompt shapes a probability distribution over what the model does next. Most of the time the distribution favors the safe path. But it is still a distribution, and over thousands of tool calls the tail event happens. The model misreads the context, an instruction three thousand tokens back loses weight, and the unsafe command comes out anyway. You cannot audit a probability. You cannot prove a prompt will hold on the next call.

A hook is different in kind. It is not advice to the model, it is code that sits between the model's decision and the real effect. The model can want to run the command all it likes. If the hook returns a denial, the command does not run. Prompts steer. Hooks enforce. The two do different jobs, and the second one is the only one you can actually depend on when the cost of a miss is high.

Where a hook lives

Agent runtimes expose a small set of points where you can attach your own code to the tool-execution path. The names differ across frameworks, but the shape is consistent. Two points carry most of the weight.

The first fires before a tool runs. The runtime hands your hook the tool name and its arguments, and your hook returns a decision: allow, allow with a modification, or deny with a reason. Deny here means the tool never executes and the agent receives your reason as feedback.

The second fires when the agent tries to end its turn. The runtime asks your hook whether stopping is allowed. If the work is not actually finished, your hook can block the stop and hand back an instruction to keep going.

A pre-tool hook is roughly this:

def pre_tool_use(tool_name, tool_input):
    if tool_name == "bash":
        cmd = tool_input["command"]
        if is_destructive(cmd):
            return deny(
                reason="This command can delete data. Use the "
                       "approved migration script, which takes a backup first."
            )
        if is_direct_deploy(cmd):
            return deny(
                reason="Direct deploys are blocked. Run ./scripts/deploy.sh, "
                       "which enforces the review gate."
            )
    return allow()

The important part is not the pattern matching, it is the return value. The hook does not warn and step aside. When it denies, the runtime drops the tool call. The agent's next observation is the reason string, not the command output, because the command never ran.

Three hooks that earn their place

Not every rule deserves a hook. These three cover the failures that hurt most and false-positive the least.

Pre-tool guard on destructive and privileged commands. Match on the shape of the command, not the intent behind it. Recursive force deletes, database drops and truncates, force pushes to a protected branch, direct calls to the deploy tool. Denying is only half the job. The reason you return should name the safe alternative, so the agent has somewhere to go instead of retrying the same thing.

Stop hook on unfinished work. Before letting the agent end its turn, check the objective state of the task. Is the checklist fully ticked? Did the test suite pass on the last run? If not, block the stop.

def on_stop(session):
    if session.tests_last_status == "fail":
        return block(reason="Tests are failing. Fix them before finishing.")
    if session.checklist_has_open_items():
        return block(reason="Checklist still has open items. Complete them.")
    return allow_stop()

This is the hook that stops the premature "done." The agent cannot declare victory while the objective signals say otherwise, because the declaration itself is a tool call the hook intercepts.

Secret scan before commit. Intercept the commit and inspect what is staged. If a .env, a private key, a credentials.json, or a line matching a known key pattern is in the set, deny.

def pre_tool_use(tool_name, tool_input):
    if is_commit(tool_name, tool_input):
        for path in staged_files():
            if looks_like_secret(path) or contains_key_material(path):
                return deny(
                    reason=f"{path} looks like a secret. Unstage it and add "
                           f"it to .gitignore before committing."
                )
    return allow()

A secret in git history is expensive to remove and has to be treated as leaked once pushed. This hook is cheap and the failure it prevents is not.

Design principles that keep hooks useful

A few rules separate a guardrail that helps from one that becomes noise.

Fail closed. When the hook cannot tell whether an action is safe, it should block, not allow. A blocked safe action costs you one retry and a clearer instruction. An allowed unsafe action costs you the incident. The asymmetry is the whole reason the hook exists, so let the ambiguous case fall on the safe side.

Make the denial actionable. A hook that returns "denied" and nothing else leaves the agent to guess, and it will often guess a near-variant of the same bad command. A hook that says why, and names the sanctioned path, turns a block into a redirect. The agent reads the reason, takes the approved route, and the task continues. The reason string is not a log line, it is the agent's next input.

Concentrate on high-cost actions. Every rule you add is a rule you maintain and a chance to block something legitimate. Spend that budget where a mistake is irreversible or expensive: production data, deploys, credentials, anything you cannot undo. Do not gate ordinary edits and reads. Over-blocking trains the operator to distrust the guardrail and kills the agent's throughput, which defeats the point of running one.

Match on shape, and accept some false positives. You cannot enumerate every dangerous command. Match on structural signals: the recursive-force flag, the drop verb, the protected branch name, the secret-shaped filename. You will occasionally catch something harmless. That is the correct trade when the alternative is missing the real one, as long as the denial explains itself well enough to clear quickly.

The tradeoffs you are accepting

Hooks are not free, and pretending they are is how you end up with a guardrail nobody trusts.

They are code, so they carry maintenance. A brittle regex that blocks a legitimate command every third run gets disabled by an annoyed operator, and a disabled guardrail protects nothing. Rules drift out of date as the project changes. Each hook is a small program with its own bugs, and a bug in a guardrail is worse than no guardrail, because you were counting on it.

False positives have a real cost beyond the retry. Every wrongful block erodes trust in the whole system. Cross a threshold and people start routing around the guardrail, running the agent in a mode where the hooks do not fire, which returns you to the unprotected state you were trying to leave. The rule set also compounds: ten hooks interact, and an action allowed by one can be the setup for a problem another was supposed to catch.

The honest framing is a cost against a risk. The cost is maintenance and the occasional wrongful block. The risk is a destructive command, a leaked secret, an unreviewed deploy. In a low-stakes sandbox the risk is small and the hooks are overhead. Against production data, live deploys, and real credentials, the risk dominates and the hooks pay for themselves the first time one fires on the command that would have caused an incident.

When a prompt is enough

Not every rule needs a hook, and treating every rule as a hook is its own failure. Reach for a hook when three things line up: the action is high-cost or irreversible, the unsafe case is recognizable from the tool call and its arguments, and you need the rule to hold every single time rather than usually. Destructive commands, secret commits, and unreviewed deploys all fit.

A plain prompt instruction is the right tool when the stakes are low, when a rare miss is cheap to catch in review, or when the rule is about style and judgment rather than a bright line you can match on in code. "Prefer small commits" is a prompt. "Never force-push to main" is a hook. If you cannot write the check as a function that looks at a tool call and returns allow or deny, it probably belongs in the prompt, because a hook that has to guess at intent will false-positive its way into being switched off.

The split is not prompt versus hook. It is guidance versus guarantee. Use the prompt to steer the agent toward good defaults across the wide, fuzzy space of what it does all day. Use hooks to draw the few hard lines that must never be crossed, and to make crossing them impossible at the layer where the action actually happens, rather than merely discouraged at the layer where the model decides to try.