Agent Drift: The Guardrails That Actually Hold

Every team that runs agents writes rules: "run the tests before you commit", "if my message starts with Question:, change nothing". The agent follows them for an hour. Forty tool calls later it edits a file you only asked about.
This is agent drift, and research says it is normal. In LLMs Get Lost In Multi-Turn Conversation, 200,000+ simulated conversations showed top models doing worse in multi-turn than in single-turn settings, "with an average drop of 39% across six generation tasks", and "when LLMs take a wrong turn in a conversation, they get lost and do not recover". Chroma's Context Rot report tested 18 models: "model performance consistently degrades with increasing input length". Anthropic's context engineering guide calls it an "attention budget" that every new token depletes.
A rule written once at the top competes with everything that came after it. So the useful question is not how to phrase the rule. It is which layer enforces it.
Soft Rules and Hard Rules
Claude Code's own memory docs draw the line clearly. CLAUDE.md files are read at the start of every session, and "Claude treats them as context, not enforced configuration." The output styles docs say the same about styles: an output style "doesn't guarantee that something always happens or never happens."
Hooks are the other side. The hooks guide says they give "deterministic control: certain actions always happen rather than relying on the LLM." A PreToolUse hook can deny a tool call, and Claude Code then "cancels the tool call and feeds permissionDecisionReason back to Claude." Permission rules are evaluated "deny, then ask, then allow", and "an allow rule can't carve an exception out of a deny rule."
Here is how the common guardrails compare:
| Guardrail | Enforced by | Good for | Where it fails |
|---|---|---|---|
| CLAUDE.md / AGENTS.md rule | The model | Conventions, workflow order | Fades as context grows |
| Output style | The model | Tone, format, role | Hard limits |
UserPromptSubmit hook adding context |
The model, with text fresh every turn | Per-turn modes and reminders | Still advice, not a block |
PreToolUse hook that denies |
The client | Blocking a tool for a mode or path | Only sees the tools it matches |
| Permission deny rule | The client | Whole tools, whole commands | Argument patterns are fragile |
Local git pre-commit hook |
Git | Lint, tests, secret scans | --no-verify skips it |
| CI or server-side check | Your pipeline | The final gate on merges | Runs late |
| Watcher on a clean context | A second model | Judgment calls a script cannot make | It is a model too |
Rule of thumb: if breaking a rule costs you an incident, the client or the pipeline must enforce it. Prose is for rules you can live with being followed most of the time.
Worked Example: "Question:" Means Research Only
A common memory rule: a message that starts with Question: is research only. As a line in CLAUDE.md it works until it does not. Two hooks make it hold.
The hooks guide says every event gets common fields such as session_id, and "UserPromptSubmit hooks get the prompt text". For UserPromptSubmit, the hooks reference says "Claude Code adds plain-text stdout as context that Claude can see and act on." The first script sets a per-session flag and adds a reminder:
#!/usr/bin/env bash
# question-mode.sh, runs on UserPromptSubmit
input=$(cat)
sid=$(jq -r '.session_id' <<<"$input")
prompt=$(jq -r '.prompt' <<<"$input")
flag="${TMPDIR:-/tmp}/claude-question-$sid"
if [[ "$prompt" == Question:* ]]; then
touch "$flag"
echo "This turn is research only. Read and answer. Do not edit files."
else
rm -f "$flag"
fi
The second script runs before Edit and Write and denies them while the flag exists, using the JSON shape from the reference:
#!/usr/bin/env bash
# question-guard.sh, runs on PreToolUse for Edit|Write
sid=$(jq -r '.session_id')
if [[ -f "${TMPDIR:-/tmp}/claude-question-$sid" ]]; then
jq -n '{hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "The prompt started with Question: so this turn is research only. Answer, then ask before editing."
}}'
fi
Wire both in .claude/settings.json, with absolute paths to the scripts:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "/path/to/question-mode.sh" }] }
],
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "/path/to/question-guard.sh" }] }
]
}
}
Now the reminder arrives with every question, and the block does not depend on the model remembering it.
The failure mode: this guard covers Edit and Write only. The agent can still write through Bash with sed -i or a redirect. Add Bash to the matcher (strict: it also stops grep), or let the watcher below catch it. Plan mode is not a full substitute: the permissions docs say that in plan mode Claude "doesn't edit your source files", but "with auto mode available, classifier-approved commands also run."
Commit Hooks: Good Gate, Not the Last One
A git pre-commit hook runs no matter what the model believes. Per the githooks docs, a non-zero exit "causes the git commit command to abort before creating a commit." The same page says it "can be bypassed with the --no-verify option", and so can commit-msg. An agent with a failing hook and a goal to finish can reach for that flag.
You can add a deny rule such as Bash(git commit --no-verify *), but the permissions docs warn that "Bash permission patterns that try to constrain command arguments are fragile": a Bash(git push *) deny does not stop git -C . push origin main. Treat the deny rule as a speed bump. The real gate is CI or branch protection, where the agent has no shell.
A Watcher on a Clean Context
Some rules are judgment calls: "the change stays inside the ticket scope", "no new dependency without a reason", "the answer to a question turn did not modify anything". A script cannot check them. A second model can, if it does not share the drifted context.
Claude Code subagents fit this. The subagents docs say "each subagent runs in its own context window with a custom system prompt, specific tool access, and independent permissions", and a subagent can use a different model. A minimal watcher:
---
name: rule-watcher
description: Checks a finished change against the project rules. Use before every commit.
tools: Read, Grep, Glob
model: haiku
---
You review, you never fix. Read AGENTS.md and the files you are given.
For each rule, answer PASS, FAIL or UNSURE with the file and line.
A FAIL needs evidence from the files, not from the task description.
The watcher gets the rules and the changed files, not the forty turns that convinced the main agent the shortcut was fine. Give it evidence, not your conclusion, or it hands your conclusion back. To make the review automatic, a Stop hook can run a check: exit code 2 on Stop "sends Claude back to keep working".
A smaller monitor can work. Baker et al. report that "a LLM weaker than o3-mini, namely GPT-4o, can effectively monitor a stronger model" for reward hacking in agentic coding tasks. The same paper is the warning: when the monitor was put into the training reward, "agents learn obfuscated reward hacking, hiding their intent". That result is about training, not prompting, but the lesson carries over: if the worker retries until the watcher says PASS, you select for runs that look clean to the watcher. Send a second FAIL to a human.
What Does Not Work
- Longer, louder rule files. The memory docs recommend "under 200 lines per CLAUDE.md file" because "longer files consume more context and reduce adherence."
- Repeating the rule at turn 60. Per the multi-turn study, a model that took a wrong turn does not recover. Start a fresh session with the rule and a short summary instead.
- Asking the agent to check its own work in the same context. It reviews with the same assumptions that produced the drift.
- Output styles as policy. Their docs say they guarantee nothing.
- Argument patterns as a security boundary. The permissions docs say a deny rule "isn't a security boundary around the program."
- Local hooks as the only gate.
--no-verifyexists.
Approval prompts belong on this list too, for the reasons in our posts on human approval for agent commands and on containers versus permission prompts.
How Akmatori Splits Soft and Hard Rules
An SRE agent has the same problem with higher stakes. Akmatori uses both layers, as the architecture docs describe. The soft layer is AGENTS.md: the prompt tells the agent to search runbooks, then memory, then recent change events, and only then call infrastructure tools. The hard layer is the MCP Gateway: each run gets a tool allowlist from its run kind, and "the gateway refuses every other tool". Proposal review chats get no connector tools and their memory writes are refused. The self-improvement evaluator runs without connector tools.
The docs also say "a skill does not restrict tools." So a line like "never use SSH" in a skill prompt is a soft rule. To withhold a connector, disable its tool instance, and the gateway enforces it.
Checklist
- Mark each rule by the cost of breaking it. High-cost rules go to a hook, a deny rule, the tool allowlist or CI, never only prose.
- Keep the rule file short. Use
UserPromptSubmitcontext for per-turn modes such asQuestion:. - Know which tools each hook does not match, especially
Bash. - Run a read-only watcher on a clean context before commit, and cap its retries.
- Keep the final gate where the agent has no shell.
Conclusion
Drift is what long contexts do to rules written once at the top. Move every rule that matters out of the model's memory and into something that runs: a hook, a deny rule, a gateway allowlist, a pipeline check. Leave the judgment calls to a watcher on a clean context.
If your team wants AI-assisted incident workflows with tool limits enforced outside the model, Akmatori helps SRE teams investigate alerts, coordinate response, and automate safe infrastructure actions. Powered by Gcore for global infrastructure reliability.
