Building a Safe Shell Tool for Your Agent Harness
Agent shell tool safety comes down to allowlists, argument validation, and never using shell=True. A step-by-step guide to a shell tool you can trust.
import subprocess
def shell_tool(argv: list[str], timeout=15):
if not isinstance(argv, list) or not argv:
return "ERROR: expected a non-empty list of arguments"
try:
out = subprocess.run(argv, capture_output=True, text=True,
timeout=timeout, shell=False) # never True
return (out.stdout + out.stderr)[:6000] or "(no output)"
except subprocess.TimeoutExpired:
return f"ERROR: '{argv[0]}' timed out after {timeout}s"
except FileNotFoundError:
return f"ERROR: command not found: {argv[0]}"Picture this: you're a platform engineer, it's Friday afternoon, and you just gave your agent a
shellgit statuslsrm -rfThis guide builds a shell tool you can actually trust, step by step.
Quick-Start (Copy This Right Now)
The first rule is the one people skip: never build a shell string and never use
shell=True[object Object], subprocess
,[object Object], ,[object Object],(,[object Object],):
,[object Object], ,[object Object], ,[object Object],(argv, ,[object Object],) ,[object Object], ,[object Object], argv:
,[object Object], ,[object Object],
,[object Object],:
out = subprocess.run(argv, capture_output=,[object Object],, text=,[object Object],,
timeout=timeout, shell=,[object Object],) ,[object Object],
,[object Object], (out.stdout + out.stderr)[:,[object Object],] ,[object Object], ,[object Object],
,[object Object], subprocess.TimeoutExpired:
,[object Object], ,[object Object],
,[object Object], FileNotFoundError:
,[object Object], ,[object Object],What this does: it runs a command from an argument list, with the shell disabled, so
["ls", "; rm -rf /"]; rm -rf /Understanding the Variables
Three ideas drive shell tool safety, and getting them straight up front saves you from the two dead-end approaches most people try first.
Allowlist, not blocklist. The tempting move is to ban dangerous commands — block
rmcurlddrm/bin/rmfind -deleteArgument validation, not just command validation. Allowing
gitgitgit statusgit loggit pushNo shell interpretation, ever. Covered above, but it's the load-bearing idea. The moment you interpolate model output into a string a shell parses, you've lost.
Worth understanding why the argument-list form is safe, because the reason is precise. When you call
subprocess.run(["ls", user_input])ls$()⚡ Pro tip: Log the exact
argvAgent Shell Tool Safety, Step by Step
Now build the allowlisted version. Each permitted command gets a validator that inspects its arguments:
ALLOW = {
,[object Object],: ,[object Object], a: ,[object Object],(,[object Object], x.startswith(,[object Object],) ,[object Object], x ,[object Object], (,[object Object],, ,[object Object],) ,[object Object], x ,[object Object], a),
,[object Object],: ,[object Object], a: a ,[object Object], a[,[object Object],] ,[object Object], (,[object Object],, ,[object Object],, ,[object Object],, ,[object Object],),
,[object Object],: ,[object Object], a: ,[object Object],(a) == ,[object Object], ,[object Object], ,[object Object], ,[object Object], ,[object Object], a[,[object Object],],
,[object Object],: ,[object Object], a: ,[object Object],,
}
,[object Object], ,[object Object],(,[object Object],):
,[object Object], ,[object Object], argv:
,[object Object], ,[object Object],
cmd, args = argv[,[object Object],], argv[,[object Object],:]
,[object Object], cmd ,[object Object], ,[object Object], ALLOW:
,[object Object], ,[object Object],
,[object Object], ,[object Object], ALLOW[cmd](args):
,[object Object], ,[object Object],
,[object Object], shell_tool(argv, timeout) ,[object Object],What this does: it checks the command against an allowlist, runs that command's argument validator, and only then hands off to the shell-disabled runner. A blocked command returns a clear message listing what is allowed, so the model corrects itself instead of flailing. Notice
gitpushreset⚡ Pro tip: Make the "allowed commands" list part of the tool description the model reads. When the model knows up front that only
statuslogdiffbranchgitgit pushPro-Level Variations
Three upgrades cover most real deployments:
Run inside a jailed working directory. Set
cwdcat/etcAdd a confirmation gate for a second tier. Some teams keep a small set of "allowed with human approval" commands — the agent proposes, a person clicks yes. The harness pauses, surfaces the exact
argvDrop the whole thing in a container. For untrusted contexts, combine the allowlist with the disposable-container pattern so even a validator bug can't escape the box.
Three teams putting this to work:
- A DevOps engineer gives an incident-response agent read-only shell access — ,
kubectl get,logs— with write commands excluded entirely, so the agent can diagnose an outage but never change cluster state.describe - A data engineer allows and
dbt runwith validated model names, so the agent can trigger pipelines but can't run arbitrary SQL against production.dbt test - A QA automation lead permits a fixed set of test-runner commands in a container, letting the agent reproduce failing tests without any path to the host.
Auditing What the Agent Actually Ran
Safety isn't only about prevention — it's about being able to answer "what did the agent do?" after the fact. A shell tool should keep a durable record of every command it executed, separate from the model's transcript, because the transcript can be summarized or trimmed while an audit log must be complete.
[object Object], json, time
,[object Object], ,[object Object],(,[object Object],):
entry = {,[object Object],: time.time(), ,[object Object],: argv}
result = safe_shell(argv, timeout) ,[object Object],
entry[,[object Object],] = result.startswith(,[object Object],)
entry[,[object Object],] = result[:,[object Object],]
,[object Object], ,[object Object],(logfile, ,[object Object],) ,[object Object], f:
f.write(json.dumps(entry) + ,[object Object],)
,[object Object], resultWhat this does: it appends a structured record of every attempted command — the exact arguments, whether it was blocked, and a preview of the output — to an append-only log before returning. When someone asks what the agent touched last Tuesday, you have the answer, and when the agent gets blocked repeatedly, the log shows you which command it keeps reaching for so you can decide whether to allow it.
This log is also your best signal for tightening the allowlist safely. If a command has been allowed for a month and never once used, remove it — an unused permission is pure risk with no benefit. If a blocked command appears constantly and it's genuinely safe, that's your cue to add it deliberately. The audit log turns allowlist maintenance from guesswork into evidence.
⚡ Pro tip: Alert on the rate of blocked commands, not just their presence. A sudden spike in blocked attempts often means the model has gone off the rails — misunderstanding the task and probing for tools it shouldn't need. That spike is an early warning that something's wrong with the run, visible in the audit log before it shows up in the output.
A compliance-focused engineer at a bank uses exactly this log to satisfy an auditor's requirement that every automated action against infrastructure be reconstructable months later — the append-only JSONL is the evidence trail, independent of whatever the model's context window remembers.
Troubleshooting Common Issues
⚠️ Common mistake: Reaching for
shell=TruesubprocessglobOther issues you'll hit:
- The agent keeps requesting blocked commands. Your tool description doesn't list what's allowed. Add the allowlist to the description and the requests realign.
- A permitted command hangs. Something's waiting on stdin. Pass so an interactive prompt fails fast instead of freezing the run.
stdin=subprocess.DEVNULL - Validation is too strict and blocks legitimate work. Loosen the specific validator, not the allowlist as a whole. Widening one command's arguments is safe; adding a command needs real thought.
- The agent quotes a whole command as one argument. You'll see it pass instead of
["git status"]. Split on whitespace in the tool description's examples so the model learns to send pre-tokenized arguments, and validate that the first element is a bare command name with no spaces.["git", "status"]
Your Turn
Start with the two non-negotiables today: argument lists instead of strings, and
shell=FalseThe allowlists and command descriptions that make a shell tool safe are worth keeping as reusable assets, because every new agent that touches a shell needs the same guardrails. Storing your proven allowlist-plus-description pairs in a prompt library like PromptABCD — tagged by the environment they're safe in — means your next shell agent starts locked down by default. Agent shell tool safety is a habit, and habits are easier to keep when the safe version is the one you can paste in from a library you already trust.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
