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 shell tool so it can run git status and ls for you. It's working beautifully. Then, testing an edge case, you watch it run rm -rf on a path built from a variable it misread. Your stomach drops. The command was valid. The tool ran it. Nothing was malicious — the harness simply did exactly what it was told. Agent shell tool safety is the discipline that stands between "handy automation" and that Friday-afternoon feeling.
This 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. Pass arguments as a list so nothing the model writes can inject extra commands.
[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 /"] runs a program literally named ; rm -rf / (which doesn't exist) instead of chaining a destructive command. Disabling the shell closes the entire injection class in one line. This is the foundation everything else sits on.
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 rm, curl, dd. Blocklists always lose, because there are infinite ways to spell danger (rm, /bin/rm, find -delete, a Python one-liner). An allowlist inverts the problem: only named commands run, and everything else is refused by default. You reason about a short list of permitted actions instead of an infinite list of forbidden ones.
Argument validation, not just command validation. Allowing git isn't enough — git can push, reset, and delete branches. Safety lives at the argument level: allow git status and git log, refuse git push unless you meant to.
No 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]), the operating system receives ls and its arguments as separate, already-split values — there's no shell in between to interpret semicolons, pipes, backticks, or $() substitutions. Those metacharacters only have power when a shell parses a single combined string. Remove the shell and they become ordinary text, passed literally to the program as an argument it'll almost certainly reject. That's the whole mechanism, and it's why "just sanitize the input" is the wrong fix: you don't need to sanitize what's never interpreted. Blocklisting characters is an arms race; removing the interpreter ends the war.
⚡ Pro tip: Log the exact argv list for every call before it runs, not after. When something goes wrong, the pre-execution log shows you precisely what the harness was about to do — including the malformed argument that caused the problem — which the post-execution log can miss if the command hangs.
Agent 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 git permits read operations and silently excludes push and reset — the model can't destroy history because destruction was never on the menu.
⚡ Pro tip: Make the "allowed commands" list part of the tool description the model reads. When the model knows up front that only status, log, diff, and branch are available under git, it stops asking for git push and your rejection rate drops. Describe the boundary and the model respects it far more often than it tests it.
Pro-Level Variations
Three upgrades cover most real deployments:
Run inside a jailed working directory. Set cwd to a dedicated scratch folder and validate that no argument resolves outside it. This pairs allowlisting with containment, so even a permitted cat can't wander into /etc.
Add 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 argv, and only runs on approval.
Drop 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,describe— with write commands excluded entirely, so the agent can diagnose an outage but never change cluster state. - A data engineer allows
dbt runanddbt testwith validated model names, so the agent can trigger pipelines but can't run arbitrary SQL against production. - 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=True because a command "needs" a pipe or a wildcard. It doesn't — do the piping in Python. Run each stage as a separate subprocess call and connect them in code, or expand globs with the glob module before building your argv. Every time you enable the shell to save a few lines, you re-open the injection door you spent this whole guide closing.
Other 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
stdin=subprocess.DEVNULLso an interactive prompt fails fast instead of freezing the run. - 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
["git status"]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.
Your Turn
Start with the two non-negotiables today: argument lists instead of strings, and shell=False. Those alone close the injection class that causes the worst incidents. Add the allowlist the first time you feel nervous about what the agent might run, and reach for containers when the code's source stops being trustworthy. Build up the layers as the threat grows — don't front-load paranoia you don't need yet.
The 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.
