TL;DR
I gave my autonomous coding agent real shell access, then spent two weeks building guardrails around it after a rm -rf near-miss. The result is a three-tier command classifier that parses shell syntax instead of regex-matching it, plus filesystem and network confinement and an append-only audit log. Interruption rate dropped from 31% of commands to 4%, and I stopped watching the terminal like a hawk.
The Problem
An agent that can't run shell commands is a very expensive autocomplete.
The whole point of a fully autonomous implementation system is that it closes the loop by itself: write code, run the tests, read the failure, fix it, run the tests again. Take away Bash and every one of those steps needs me. I tried the read-only-agent approach for about a week. It produced code that looked correct and was never once verified.
So I turned shell access on. And then I watched it do this:
# the agent, "cleaning up" a test fixture directory
rm -rf $BUILD_DIR/artifacts
BUILD_DIR was unset in that subprocess. That expands to rm -rf /artifacts. On my machine that particular path didn't exist, so nothing happened, and I only noticed because I was reading the transcript for an unrelated reason.
That's the real problem with agent shell access. It isn't that the agent is malicious — it obviously isn't. It's that:
-
The agent doesn't know what it doesn't know. Unset variables, a different working directory than it assumed, a
gitremote that isn't the one it thinks it is. -
Failures are silent and asymmetric. 400 successful
npm testruns don't earn back onegit push --forcetomain. -
The obvious fix makes the agent useless. My first attempt was "confirm every command." I approved 180 prompts in one afternoon, 174 of which were
ls,cat, andpytest. I started rubber-stamping by hour two, which is strictly worse than no gate at all — I had the feeling of oversight without the substance.
What I actually wanted: the agent runs the boring 95% at full speed, and I get a hard stop on the 5% that can ruin my day.
How I Solved It
Three layers. Each one catches a different class of mistake.
flowchart TD
A[Agent proposes command] --> B[Layer 1: Parse and classify]
B -->|deny| C[Blocked, reason returned to agent]
B -->|ask| D[Human confirm]
B -->|allow| E[Layer 2: Confined execution]
D -->|approved| E
D -->|rejected| C
E --> F[Layer 3: Append-only audit log]
F --> G[Output back to agent]
Layer 1: Classify by parsing, not by regex
This is the part I got wrong first, so it's the part worth the most words.
My original rules were regex against the raw command string. ^rm\s+-rf\s+/ and friends. It took the agent about three days to walk straight through one, not because it was trying to, but because it wrote a perfectly ordinary command that my pattern didn't anticipate:
cd "$(git rev-parse --show-toplevel)/tmp" && rm -rf ./*
No leading slash. No match. My regex was looking at text; the danger was in the semantics.
The fix was to stop pattern-matching strings and start parsing shell into a syntax tree. Python's shlex gets you tokenization; bashlex gets you actual structure — pipelines, redirects, command substitutions, && chains. Once you have the tree you can walk every simple command inside a compound one and classify each independently.
import bashlex
DENY = {
("rm", "-rf", "/"), # root deletion, any form
("git", "push", "--force"),
("chmod", "777"),
}
ASK = {"git push", "git reset --hard", "docker", "brew", "pip install", "npm publish"}
ALLOW = {"ls", "cat", "grep", "rg", "find", "git status", "git diff",
"git log", "pytest", "npm test", "make", "head", "tail", "wc"}
def simple_commands(src: str):
"""Yield every simple command inside a compound command."""
for tree in bashlex.parse(src):
for node in _walk(tree):
if node.kind == "command":
yield [p.word for p in node.parts if p.kind == "word"]
def classify(src: str) -> tuple[str, str]:
try:
commands = list(simple_commands(src))
except bashlex.errors.ParsingError:
return "ask", "unparseable shell; falling back to human review"
verdicts = []
for words in commands:
if not words:
continue
verdicts.append(_classify_one(words))
# The whole pipeline is only as safe as its most dangerous link.
for level in ("deny", "ask", "allow"):
for verdict, reason in verdicts:
if verdict == level:
return verdict, reason
return "ask", "no rule matched"
Two design decisions in there that I'd defend to anyone:
Unparseable means ask, never allow. If my parser can't understand the command, I have zero information about it, and zero information is not the same as "safe." Early on I had this fall through to allow because parse failures were annoying. They were annoying because they were mostly heredocs and process substitution — exactly the constructs where interesting things hide.
The most dangerous link decides. npm test && rm -rf ./dist is not an allow just because it starts with one. Walking every simple command in the tree and taking the worst verdict is the only version of this that holds up.
Layer 2: Confine what "allow" can reach
Classification decides whether to run. Confinement decides how much it can touch when you get the classification wrong — and you will get it wrong.
Three cheap constraints, none of which required a container:
import os, subprocess
def run_confined(cmd: str, workdir: str, timeout: int = 300):
env = {
"PATH": "/usr/bin:/bin:/usr/local/bin",
"HOME": workdir, # keep stray writes inside the work tree
"LANG": "en_US.UTF-8",
# Note what is NOT here: every API token in my real shell.
}
return subprocess.run(
["/bin/bash", "-o", "pipefail", "-c", cmd],
cwd=workdir,
env=env, # allowlist, not os.environ.copy()
capture_output=True,
text=True,
timeout=timeout, # no unbounded hangs
)
The env allowlist is the highest-value line in this entire post. The default instinct is os.environ.copy(), which hands the agent every credential you've ever exported — and those credentials end up in command output, which ends up in the model's context, which ends up in logs. Starting from an empty dict and adding back only what's needed took twenty minutes and removed an entire category of problem.
For network I run the agent's shell under a user account whose outbound traffic is filtered to a host allowlist (package registries, my git remote, and nothing else). The agent can npm install. It cannot curl an arbitrary host and pipe the result to bash.
Layer 3: Log everything, append-only
Every command, verdict, exit code, and duration goes to a JSONL file that the agent can write to but not read or rewrite:
import json, time
def audit(cmd: str, verdict: str, reason: str, exit_code: int | None, ms: int):
with open(AUDIT_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps({
"ts": time.time(), "cmd": cmd, "verdict": verdict,
"reason": reason, "exit_code": exit_code, "duration_ms": ms,
}, ensure_ascii=False) + "\n")
This started as a debugging aid and turned into the tuning instrument for the whole system. Once a week I run:
jq -r 'select(.verdict=="ask") | .cmd' audit.jsonl \
| awk '{print $1, $2}' | sort | uniq -c | sort -rn | head -20
Anything at the top of that list that I approved every single time is a rule that's costing me attention and buying me nothing. It gets promoted to allow. That one query is how the interruption rate went from 31% to 4% without loosening anything that mattered.
Lessons Learned
1. A gate you rubber-stamp is worse than no gate. Confirming everything trained me in about ninety minutes to approve without reading. The security value of a prompt is exactly the attention you still bring to it, and attention is a budget that depletes. Spend it on the 4%.
2. Regex over shell strings is security theater. Shell has command substitution, variable expansion, quoting, and chaining. Any of those turns a dangerous command into text your pattern doesn't match. Parse it or don't pretend to check it.
3. Fail toward the human, never toward execution. Every ambiguous case — parse error, unknown binary, no matching rule — resolves to ask. This produces more prompts early, which is uncomfortable, and then the audit log tells you exactly which ones to relax.
4. The environment is the attack surface nobody looks at. I spent days on command classification while every API key I own was being handed to every subprocess. An env allowlist is twenty minutes of work and it's probably the single highest-leverage thing in this post.
5. Measure the interruption rate or you're guessing. "Does this feel safe" is not a metric. Commands-per-prompt is. Track it, and guardrail tuning becomes an ordinary optimization problem instead of a vibes argument with yourself.
What's Next
Two things I haven't solved:
-
Per-command network policy. Right now the allowlist is process-wide. I'd like
npm installto reach the registry and nothing else to reach anything, which probably means a real sandbox rather than a filtered user account. - Learned classification. The rule set is hand-written and it's at 140 lines. I'd like to feed the audit log back in and have consistently-approved patterns propose their own promotion — with me approving the promotion, not the individual commands.
Stack this was built and tested on, since all of it rots: Claude Code (2026-09 release), Python 3.13, bashlex 0.18, macOS 15 and Ubuntu 24.04.
Wrap-up
If you're giving an agent shell access — and you should, a verified loop is worth far more than a supervised one — start with the env allowlist. It's an afternoon at most. Then add the audit log, run for a week, and let the data tell you where the real gate belongs. Don't start with 200 rules; you'll write the wrong ones.
If you've built something similar, I'd genuinely like to know how you handled the network layer — it's the part I'm least happy with. Drop it in the comments.
And if this was useful, follow me here on Dev.to — I write up what I learn building autonomous coding systems, war stories and dead ends included. 🚀
Top comments (0)