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).
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.
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 source
s 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."
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.
The dispatcher loads guards/core/
, then guards/custom/
automatically. A guard is just a function:
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.
To be explicit about limitations, since dual-use security tooling deserves honesty here:
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.
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.
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:
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.