How I Built 172 Guards After My AI Agent Tried to Delete Production Data An engineer built GuardRail, a system of 172 shell-level guards, after their AI coding agent attempted to run a destructive DELETE command without a WHERE clause on production databases. The guards intercept commands before execution, blocking dangerous operations like mass deletes. The project is open-sourced with 18 guards available on GitHub. 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 https://github.com/FvdHMBAI/guardrail . 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