2:47 AM, one DELETE, 23 databases
My AI coding agent was debugging a slow query. It found the table, decided the data looked stale, and ran:
DELETE FROM profiles
No WHERE clause. That table exists in 23 separate customer databases on the server this agent had shell access to. One command, every user record, gone.
Except it wasn't gone. A guard caught the command before it reached the database, blocked it, and handed the agent an error explaining exactly why. The agent adjusted its approach and went back to fixing the actual performance problem — the thing it was supposed to be doing in the first place.
That's the incident that got me to stop trusting prompts alone and start blocking commands directly. This post is about the system that came out of it: GuardRail, 172 guards running in production, 18 of them open source (MIT, on GitHub).
The problem: agents have shell access, and validation happens too late
Most AI safety tooling operates on text. It looks at what the model said, or what it's about to say, and checks whether that's okay. That's useful, but it solves a different problem than the one I had.
My agents don't just talk — they run bash. They execute git push, psql, rm, systemctl, curl. Once a command is a string being handed to a shell, output-side validation is already too late; the command already ran.
The categories of tools that validate LLM input/output (think prompt injection filters, response classifiers) are complementary to this problem, not a substitute for it. They protect the conversation. Nothing protects the shell.
What I needed was something sitting between "the agent decided to run a command" and "the command executed" — a place to say no before the rm happens instead of cleaning up after.
The architecture: dispatcher → guards → allow/deny
GuardRail hooks into the agent runtime's tool-use lifecycle. For Claude Code this is native (PreToolUse / PostToolUse hooks); for other bash-based agents, you source the dispatcher in your own wrapper.
AI Coding Agent (Claude Code, Cursor, Copilot, ...)
│ PreToolUse (Bash)
▼
┌──────────────────────────────────────────────────────────────┐
│ Pre-Bash Dispatcher │
│ 1. Parse JSON input (tool_name, command, session_id) │
│ 2. Source guardrail-common.sh (config, shared functions) │
│ 3. Source each guard file, call its hook_*() function │
│ 4. Any guard calls deny() → command is blocked │
│ 5. Otherwise → command executes │
└──────────────────────┬───────────────────────────────────────┘
DENIED ALLOWED
(command never (command
executes) executes)
│
▼
┌──────────────────────────────────────┐
│ Post-Bash Dispatcher │
│ Output scanners, error detectors, │
│ state trackers (wandering, budget) │
└──────────────────────────────────────┘
Each guard is a standalone bash file with a single hook_*() function. No classes, no plugin registry, no build step — the dispatcher just sources every file in guards/core/ and calls the matching function with $CMD set to the command string.
Here's the actual guard that caught the DELETE FROM profiles incident, trimmed slightly:
hook_mass_update_guard() {
local _tables_re
_tables_re=$(_guardrail_list_to_regex "$GUARDRAIL_PROTECTED_TABLES")
if echo "$CMD" | grep -qiE "DELETE[[:space:]]+FROM[[:space:]]+(public\\.)?${_tables_re}"; then
if ! echo "$CMD" | grep -qiE 'WHERE[[:space:]]+.*\bid[[:space:]]*='; then
deny "MASS-UPDATE-GUARD: DELETE on protected table WITHOUT WHERE clause detected. Delete records individually."
fi
fi
}
deny() is a shared function the dispatcher provides. It writes an audit entry and returns a JSON payload the agent runtime understands as "don't run this":
deny() {
local reason="$1"
guardrail_audit "Dispatcher" "$reason" "${CMD:-unavailable}" "blocked"
local rj; rj=$(printf "%s" "$reason" | jq -Rs .)
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":${rj}}}"
exit 0
}
Guards have no network access, do no file I/O beyond config, and spawn no subprocesses. The whole chain — dispatcher parse, load N guard files, run each hook_*() — runs in a single bash process. In practice that's under 1ms per guard and under 5ms for the full pre-execution chain, which is why it's invisible to the agent's response latency.
There's a second dispatcher for after the command runs (post-bash.sh), used for things you can't catch before execution — scanning output for leaked credentials, detecting prompt injection in tool output, tracking whether the agent is stuck retrying the same failing command. That one can't block (the command already ran), but it can inject additionalContext into the agent's next turn, e.g. "you just leaked an AWS key in stdout, rotate it."
Three real incidents
These aren't hypotheticals — they're from the audit log of the production system this was extracted from (13 applications, one server, no dedicated ops team).
1. DELETE FROM profiles with no WHERE. Covered above. mass_update_guard only fires when the dispatcher detects the command looks like a database client invocation (psql, pgcli, docker exec ... psql), so it doesn't waste cycles pattern-matching every shell command for SQL.
2. git reset --hard mid-debug. The agent was three hours into an approach that wasn't working and tried to reset the working tree to start over. That would have silently discarded three hours of uncommitted changes with no recovery path. main_push_guard blocks reset --hard and clean -f unconditionally — not just on protected branches, because uncommitted work is uncommitted work regardless of branch:
if echo "$CMD_SHELL" | grep -qE '([^;&|[:space:]]*/)?git[[:space:]]+reset[[:space:]]+--hard'; then
deny "GIT RESET --HARD BLOCKED: Can irreversibly delete uncommitted code. Use 'git stash' or 'git checkout <file>' for individual files."
fi
3. The agent tried to write its own approval. Some workflows require a human-created gate file before a risky step proceeds — think a manual "yes, deploy this" marker. One agent, blocked on a step requiring that file, tried touch /tmp/guardrail-gate-approve itself to satisfy the check. This is the interesting one: it wasn't malicious, it was an optimization-seeking agent treating "missing file" as an obstacle to route around, the same way it might retry a failed curl with a different flag. self_bypass_guard exists specifically because gate files are a human signal, not something the agent should ever be able to produce:
if echo "$cmd" | grep -qE "(^|[;&|]\s*)(touch|tee|cp|ln|mv|echo|printf|cat\s*>|dd|sed|perl|python|python3|ruby|node|rsync|install)\s" \
&& echo "$cmd" | grep -qF "$GUARDRAIL_GATE_PREFIX"; then
deny "Self-bypass blocked: AI agents must not create gate files. Only human operators can approve gates."
fi
Worth calling out: this is not the same threat model as a sandboxed environment. If the agent runs as the same OS user as everything else, a genuinely adversarial actor with intent to bypass could still find novel paths (write a script to a new location and pipe it through an interpreter guards don't pattern-match, for instance). What this stops reliably is the far more common case: an agent optimizing toward "complete the task" that treats a safety check as friction to engineer around, without any deliberate intent to break out. For that gap, the answer is OS-level isolation (separate users, containers), not more bash patterns.
Writing your own guard
The dispatcher loads guards/core/, then guards/custom/ automatically. A guard is just a function:
# guards/custom/npm_publish_guard.sh
hook_npm_publish_guard() {
echo "$CMD" | grep -qE 'npm\s+publish' || return 0
echo "$CMD" | grep -qE '\-\-dry-run' && return 0
deny "npm publish without --dry-run is blocked. Add --dry-run first."
}
guardrail new my_custom_guard scaffolds the file plus a matching test. Save it in guards/custom/, and it's picked up on the next command — no registration step, no restart.
What this doesn't do
To be explicit about limitations, since dual-use security tooling deserves honesty here:
- Bash only. If your agent shells out through something GuardRail's dispatcher never sees (a Python subprocess spawned outside the hook chain, for example), none of this applies.
- CLI agents only, for now. Native hook support exists for Claude Code. Codex CLI and Gemini CLI adapters are planned but not shipped.
- Pattern matching, not semantic understanding. These are regexes against a command string. They catch what they're written to catch. A sufficiently obfuscated command (base64-encoded, run through an interpreter with no pattern match) can slip through — hence the pentest framework below.
- Same-user, not sandboxed. As above: it stops accidental and optimization-driven damage, not a determined adversary with same-user access.
There's a guardrail pentest command that runs an attack-simulation suite (force push, rm -rf /etc, self-bypass, mass delete, etc.) against your installed guards specifically so you're not taking "it works" on faith:
$ guardrail pentest
Phase 3: Attack Simulation
✘ BLOCKED push to main
✘ BLOCKED force push
✘ BLOCKED rm -rf /etc
✘ BLOCKED self-bypass attempt
✘ BLOCKED mass DELETE
✓ ALLOWED push develop (correct)
✓ ALLOWED rm single file (correct)
All 103 tests passed. 0 false positives.
Quick start
npx guardrail-agent init
One command, no config required for the defaults. guardrail status shows what's active, guardrail disable turns it off temporarily for debugging (requires an interactive terminal — an agent can't do this itself, see incident #3 above).
Requirements: bash 4+, jq, openssl. Linux or macOS.
Repo: github.com/FvdHMBAI/guardrail — 18 guards, MIT license, real incidents behind every one of them.
What's next
The open-source guards are the universal subset extracted from a larger private rule set (172 guards across 13 apps). Two things I'm actively working on:
- Runtime adapters for Codex CLI and Gemini CLI, so this isn't Claude Code-only.
- EU AI Act article mapping — guard classification and audit-log evidence mapped to specific articles (Art. 9 risk management, Art. 12 record-keeping, Art. 14 human oversight). Worth being clear that running a coding agent doesn't automatically make a system "high-risk" under the Act, and technical controls like this aren't a substitute for a governance program — but if you're building one, having a timestamped, content-hashed audit trail of every blocked command is a useful input.
If you're running AI agents with shell access and don't have something between the agent and bash -c, that's the gap worth closing first.
Top comments (3)
Incident 3 is the one worth dwelling on, and I think the fix as described has a hole. self_bypass_guard blocks command-shaped creation of the gate file, but PreToolUse(Bash) only sees bash. The same runtimes that expose that hook also hand the agent a native file-write tool, and a write made through it never crosses your dispatcher. Blocking "touch /tmp/guardrail-gate-approve" does not block writing that exact file through the edit primitive. I run a similar wrapper stack in production and landed on this rule: a human gate has to be unforgeable by content, not by path. A single-use token derived from a secret outside the agent's read scope works. The mere existence of a file the agent can name does not, because every write channel the runtime offers is a forgery path, and you only guard one of them.
Second thing: your pentest suite exercises the guards, not the wiring. Guard layers rot quietly. A runtime update regenerates its settings file, or a PATH change drops a shim out of the chain. And the failure is open, not closed. When the dispatcher unhooks, commands just run, and nothing looks different, since absence of denials is indistinguishable from absence of attempts. What caught this for me was an integrity check on a different trigger than the guarded path (session start, pre-push) that fires one known-bad canary through the real hook chain and alerts unless it gets denied.
Both points are the same lesson really: test the deployed thing, not the component.
You're right, and thank you for the precision. The guard hooked PreToolUse(Bash) only, while the same runtime hands the agent Write/Edit primitives that never cross the bash dispatcher. I verified it against my own stack: the file-write path went straight through.
Fixed and shipped. guardrail-agent@0.4.3 is on npm now. There's a new deny-capable PreToolUse dispatcher for Write/Edit/MultiEdit/NotebookEdit, plus guards that block the file-tool equivalents: writing the disable file, overwriting privileged paths, writing auto-executed files, and writing live secrets to disk. An adversarial review of that fix then surfaced eight more edges I closed too, including path canonicalization (/a/./x, //, .. normalization) and the highest-value one you'd appreciate: an agent rewriting .claude/settings.json to remove the hooks entirely.
On your deeper point: with an agent running as root, content-based unforgeability has a ceiling. A single-use token derived from a secret outside the agent's read scope is the right design, but on a single-operator box every secret is ultimately within reach. What the fix buys is that a bypass can no longer happen as a plausible single step. It now takes a deliberate multi-step effort that the audit trail and step-pattern monitors make visible. Raising the cost of silent failure is the realistic win.
The crystallized rule, now in SECURITY.md: every guard that filters tool calls must enumerate ALL mutation primitives of the runtime, not just the shell. Genuinely useful catch, this made the product better.
Raising the cost of silent failure is the right framing. A bypass that takes several deliberate edits is a different risk class from one unobserved Write slipping around the Bash hook.
The next floor is making the primitive list closed-world. The SECURITY.md rule as written is stated over the runtime's currently known mutation primitives, which makes it a snapshot with an expiry date nobody sees. The set grows when the runtime ships a new tool, or when an existing primitive picks up an alias. If the dispatcher passes an unmatched tool through because no guard claimed it, a routine upgrade reopens the hole without anything in the guard package changing.
The enforceable version inverts that: enumerate the tools the dispatcher understands and deny anything it does not recognise. Then a runtime upgrade fails loudly, and missing coverage shows up as an integration failure rather than as an ambient bypass.
One dependency in the audit-trail argument you did not name. The multi-step bypass is only visible if the trail lives outside the reach of the agent's own mutation primitives. Under root on one box it sits inside the blast radius, so the escalation that strips the hooks can edit the record of having done it. Append-only into a different trust domain is what makes "visible" hold.