OPSEC for Agents: Scope, Caps, Monitoring, and Kill Switches
In April 2026, I had an allowlist with Bash(*) because I was going to narrow it later. Later never arrived. I trusted the validation hook, a regex that blocked rm on paths outside workspace/. The regex had \s+ where it should have been \s*, so rm -rf /home/rx/projects/old-backups/ without a double space got through. The agent decided to clean old directories during a maintenance session and took one I hadn't replicated to the NAS. Forty minutes restoring from the previous day's backup.
The allowlist is the first line of defense. The hook is the third. Trusting the hook without a narrow allowlist is trusting one layer when the design called for three.
You designed everything correctly. Restricted tools, hooks, audit chain, memory, multi-agent pipeline, auto-pilot with doctrine. Three months in production without incident. Then the model enters a loop: spawns a sub-agent that spawns a sub-sub-agent that tries to delete a critical config. The hooks would have caught it, if you had configured them on every agent in the chain. You only configured them on the main one.
OPSEC for agents means assuming something will go wrong. Not if, when. Your architecture needs defense layers that assume each individual layer can fail.
Scope: what the agent can touch
Scope is the first layer. It explicitly defines what's inside and outside the limits. Everything outside is denied by default.
In Claude Code, scope materializes in three places:
Permissions JSON: what tools can do.
{
"permissions": {
"allow": ["Read(./src/**)", "Edit(./src/**)", "Bash(git status:*)"],
"deny": ["Read(/etc/**)", "Read(~/.ssh/**)", "Bash(rm -rf:*)"]
}
}
addDir for sub-agents: which filesystem is visible.
Agent({
subagent_type: "reviewer",
addDir: ["./src", "./tests"] // can only see these dirs
});
The rule: deny by default. Don't list everything that's permitted, you'll miss something. Explicitly list what's denied. What remains is allowed, but the deny list is where danger gets blocked.
Caps: how much can be spent
Scope prevents bad actions. Caps prevent correct actions at the wrong volume.
Daily dollar cap: maximum spend per day. Before execution, check how much has been spent today. Block if cap is reached.
async function preCheck() {
const spent = await sumSpendingSince(startOfDay());
if (spent > DAILY_DOLLAR_CAP)
throw new Error(`daily cap exceeded: $${spent.toFixed(2)}`);
}
Tool call cap: maximum tool calls per session. Catches infinite loops where the model keeps requesting the same tool without making progress.
Time cap: maximum session length in minutes, killed via timeout. Catches stalled sessions that don't finish but also don't progress.
The combination: conservative token cap + dollar cap as safety net + tool cap for loops + time cap for stalled sessions. Each catches a different failure mode.
Find your typical daily spend. Set the dollar cap at 3x that value. If you hit it, something is wrong. Investigate before raising the cap.
Monitoring: seeing what's happening
The audit chain records. Monitoring is what you consult in near-real-time.
Aggregated dashboard: token spend today, session count, tool calls per hour. The number that matters: cost_per_outcome. When it rises without quality rising, something regressed.
Event tail: real-time event stream. tail -f audit-chain.ndjson | jq in the terminal. When something seems off, you see it passing in real time.
Alerts: rules that fire when suspicious patterns appear. Slack ping when token cap is at 80%. Email when tool call cap is reached. PagerDuty when an agent accesses a file on the deny list (blocked attempt, but signals intent).
Monitoring investment scales with risk. Toy system: nothing beyond logs. Production system: dashboard plus tail. Multi-tenant or regulated system: alerts plus on-call.
Kill switches: how to bring it down
A kill switch is what you use when something has gone wrong. It must be fast (seconds), reliable (always works), and reversible (you can come back).
File flag: the agent checks for a file's existence before each step.
touch /tmp/ralph-paused
# Ralph detects on next tick:
if [ -f /tmp/ralph-paused ]; then
echo "paused via flag, exiting"
exit 0
fi
Operator creates the flag, agent pauses. Remove the flag, agent resumes. Reversible with two commands.
SIGTERM via PM2: kills the process with a chance to clean up.
pm2 stop ralph
PM2 sends SIGTERM, the agent has a chance to release locks and flush the audit. Cleaner than kill -9.
Anti-pattern: kill switch only on the main harness. Real systems have process chains. Killing the coordinator may not kill the entire chain:
cron -> coordinator (PID 1000)
-> claude CLI (PID 1001)
-> chrome puppeteer (PID 1002)
-> child processes...
pm2 stop coordinator kills PID 1000. PID 1001 becomes orphaned. PID 1002 keeps running. Chrome still making API requests. Tokens keep burning.
Solution: process group kill.
kill -- -1000 # negative signal = kills the entire process group
Practice kill switches before you need them. Monthly fire drill. Simulate a takedown in staging. Measure the time. If the containment process takes more than five minutes, you'll have a long incident next time.
OPSEC is posture, not a checklist
The technical elements (scope, caps, monitoring, kill switches, secret hygiene, audit) are components. The part that's usually missing is culture: assuming things will go wrong, practicing response, maintaining logs, running blameless post-mortems.
An agentic system without OPSEC culture operates well until the day it doesn't. And on that day, the damage is proportional to the autonomy you granted.
In April 2026, the directory incident took forty minutes of recovery. With process group kill configured and a monthly fire drill, it would have been three. The architecture was almost right. The practice wasn't there.
Top comments (0)