Your coding agent has a shell. Now what?

The moment you give a coding agent a run_bash tool, the job changes. You are no longer building a model that produces text. You are building a system that executes arbitrary shell commands an LLM decided to run. One typo in the prompt, one misinterpreted instruction, one confused loop, and the agent can delete files, open sockets, or run the wrong migration on the wrong database.

You cannot fix this by "trusting the model." Even a perfectly aligned model will sometimes make the wrong call, especially when a tool description is ambiguous. You fix it by designing the tool so dangerous commands cannot get through, regardless of what the model asked for.

This post is the bash tool design I use for coding agents: the minimum viable safe execution layer, the traps beginners fall into, and the sandbox pattern that isolates the blast radius when (not if) the model goes off the rails.

Why can't you just wrap subprocess.run and call it a day?

Because subprocess.run('rm -rf /', shell=True) works exactly as you would expect, and an LLM that has decided the right move is to "clean up the working directory" will eventually produce that string. The naive bash tool looks like this:

# filename: naive_bash.py
# description: The bash tool you should not ship. No timeout, no limits,
# no allowlist, no output cap. This will bite you within a week.
import subprocess

def run_bash(command: str) -> str:
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    return result.stdout + result.stderr

3 things are wrong. No timeout, so a runaway command hangs the agent forever. No output cap, so cat bigfile returns 50MB into the LLM context and blows the token budget. No filter, so the model can run anything including commands that break the host.

Most tutorials stop there. That is fine for a laptop demo. It is not fine for anything you would leave running unattended or wire into a production pipeline.

What does a safe bash tool look like?

5 layers, each a few lines, each catching a different failure mode:

graph TD
    LLM[LLM requests run_bash] --> Allow[Layer 1: allowlist check]
    Allow -->|passes| Timeout[Layer 2: timeout wrapper]
    Timeout --> Sandbox[Layer 3: sandboxed subprocess]
    Sandbox --> Cap[Layer 4: output cap]
    Cap --> Result[Layer 5: structured result]

    Allow -->|fails| Block[Block and return error]

    style Allow fill:#fef3c7,stroke:#b45309
    style Sandbox fill:#dbeafe,stroke:#1e40af
    style Result fill:#dcfce7,stroke:#15803d

Every layer is independently useful. Skipping one opens a hole. Implementing all 5 takes about 30 lines.

# filename: safe_bash.py
# description: A bash tool with allowlist, timeout, sandbox, output cap,
# and structured results. Drop-in replacement for the naive version.
import shlex
import subprocess
from pathlib import Path

ALLOWED_BINARIES = {
    'ls', 'cat', 'head', 'tail', 'grep', 'find',
    'git', 'python', 'pytest', 'npm', 'node',
    'rg', 'fd', 'wc', 'echo', 'pwd',
}
MAX_OUTPUT_BYTES = 20_000
DEFAULT_TIMEOUT = 30

def run_bash(command: str, cwd: Path) -> dict:
    tokens = shlex.split(command)
    if not tokens or tokens[0] not in ALLOWED_BINARIES:
        return {'ok': False, 'error': f'binary not allowed: {tokens[0] if tokens else ""}'}

    try:
        result = subprocess.run(
            tokens,
            cwd=cwd,
            capture_output=True,
            text=True,
            timeout=DEFAULT_TIMEOUT,
            env={'PATH': '/usr/bin:/bin', 'HOME': str(cwd)},
        )
    except subprocess.TimeoutExpired:
        return {'ok': False, 'error': f'timeout after {DEFAULT_TIMEOUT}s'}

    stdout = result.stdout[:MAX_OUTPUT_BYTES]
    stderr = result.stderr[:MAX_OUTPUT_BYTES]
    return {
        'ok': result.returncode == 0,
        'exit_code': result.returncode,
        'stdout': stdout,
        'stderr': stderr,
        'truncated': len(result.stdout) > MAX_OUTPUT_BYTES,
    }

Read that line by line. shlex.split splits the command into tokens so the allowlist can inspect the actual binary, not a substring of a larger string. The allowlist is short and it contains only read-mostly tools. subprocess.run takes the token list (no shell=True) which means the model cannot chain commands with ; or && or | through the shell. The env dict strips the agent's environment down to PATH and HOME, so no secret env vars leak into child processes. The timeout and output cap protect against runaways and context blowups.

Why is an allowlist better than a denylist?

Because you cannot enumerate all the ways to hurt a system, but you can enumerate the 15 commands your agent actually needs. This is the single most important decision in the whole design.

A denylist looks like "block rm, mkfs, dd, :(){:|:&};:, curl https://evil...". It feels productive. It is not. Attackers and confused models will always find a command you did not think to block. python -c "import os; os.remove('/etc/passwd')" bypasses every rm-based denylist. find . -delete bypasses the rm check entirely. git clean -fdx wipes your working tree. The denylist grows forever and still leaks.

An allowlist says "the agent can only run these 15 binaries." Anything else is blocked by default. New binary needed? Add it deliberately. The model cannot escalate past the list because there is nothing to escalate to. This inversion is the foundation of every secure sandbox.

The trade-off is that you will hit "binary not allowed" errors during development. That is a feature. Every error is a chance to decide whether the new command is worth adding. Most of the time it is not.

How do you sandbox the working directory?

Chroot-style isolation is overkill for a coding agent. Directory-scoped isolation is usually enough. The pattern: every bash call runs inside a specific cwd you control, and any path the model provides is validated to stay inside that cwd before the command runs.

# filename: path_guard.py
# description: Ensure the model cannot reach outside the agent's workspace
# with '..', symlinks, or absolute paths.
from pathlib import Path

def safe_path(workspace: Path, requested: str) -> Path:
    candidate = (workspace / requested).resolve()
    if not str(candidate).startswith(str(workspace.resolve())):
        raise ValueError(f'path escapes workspace: {requested}')
    return candidate

Use this for every tool that takes a path: read_file, edit_file, and any bash command whose tokens include a path argument. The .resolve() call collapses .. segments and resolves symlinks, so the model cannot sneak out of the workspace by adding ../.. to a filename.

For the hard case (a bash command like cat ../etc/passwd), the workspace-scoped cwd in the subprocess call plus the path guard on any file arg is usually enough. For anything sensitive, escalate to a container sandbox with --network none --read-only as shown in the Docker Non-Root User for Agentic AI Security post. That post is the deeper dive on container isolation for agents.

How do you handle long-running commands without hanging the agent?

Timeouts are necessary but not sufficient. A 30-second cap prevents infinite hangs but it also kills legitimate slow commands like pytest or npm install. The fix is tiered timeouts.

3 tiers that work well:

  1. Fast commands (ls, grep, cat, git status): 10-second timeout.
  2. Medium commands (pytest, npm test, go build): 120-second timeout.
  3. Long commands (npm install, docker build): 600-second timeout plus a warning to the model that the command may take a while.

Pick the tier from the binary. A simple lookup table does the job. The model never sets the timeout directly, because it will always pick a value that is either too short (and kills its own test run) or too long (and hangs on the first runaway).

For a deeper walkthrough of how the bash tool fits alongside read_file, edit_file, and the full agent loop, the Build Your Own Coding Agent course walks through it module by module. The free AI Agents Fundamentals primer is the right starting point if the loop concept is still new. You can also read the end-to-end pattern in Build a Coding Agent with Claude: A Step-by-Step Guide.

When should you require human approval on a bash call?

When the command is in the allowlist but the specific invocation is high-impact. 3 examples:

  • Any git push, git reset --hard, or git clean -fdx. These are reversible but painful.
  • Any command against a production host (detected by cwd or env).
  • Any command where the model has already failed once and is now trying a more aggressive variant.

The approval flow is a pause-and-prompt. The tool returns "awaiting human approval" to the model, the runtime surfaces the command to the user, and only runs it if the user confirms. This is the same split that Claude Code uses for destructive operations and it is the single biggest lever between an agent and a deleted home directory.

What to do Monday morning

  1. Open your current run_bash tool. If it uses shell=True, fix that first. Split commands with shlex and pass a token list.
  2. Add the allowlist. Start with the 15 binaries in this post. Run your agent against a real task and note every time you hit "binary not allowed." Add only the ones you actually need.
  3. Add the 3-tier timeout and the output cap. Both are a single if and a slice; neither takes more than 5 minutes to implement.
  4. Add the path guard for every tool that takes a file argument. Verify it blocks ../../etc/passwd before shipping.
  5. Wire human approval into the most destructive commands in your allowlist. Start with git push and git reset --hard. You can always add more later.

The headline: a safe bash tool is 30 lines of defensive code on top of subprocess.run. Every layer catches a different failure mode. Skip any one of them and the next runaway command finds the hole.

Frequently asked questions

How do you make a bash tool safe for a coding agent?

Use 5 layers: an allowlist of permitted binaries, a tokenized invocation (never shell=True), a timeout, an output cap, and a path guard on any file argument. Each layer catches a different failure mode. Skipping any one leaves a hole that a confused model will eventually find. The whole pattern fits in about 30 lines of Python on top of subprocess.run.

Why is an allowlist better than a denylist for bash tools?

Because a denylist is enumerative and always incomplete. You cannot list every dangerous command, but you can list the 15 safe ones your agent actually needs. An allowlist inverts the default from permit to deny, so new commands cannot leak through without a deliberate decision. Every real-world sandbox design starts from this inversion.

What binaries should a coding agent's bash tool allow by default?

Start with read-mostly tools: ls, cat, head, tail, grep, find, wc, pwd, echo, plus language tools like python, git, pytest, npm, node. Fast search tools like rg and fd are also worth including. Keep the list short; add a binary only when a real task requires it, not in anticipation of future needs.

How do you handle timeouts for long-running commands in a bash tool?

Use tiered timeouts keyed off the binary. Fast commands get 10 seconds, medium commands like pytest get 120 seconds, long commands like npm install get 600 seconds. The model never sets the timeout directly; the tool picks it from a lookup table. This prevents the model from over-timing cheap commands or under-timing expensive ones.

When should a bash tool require human approval?

On high-impact invocations even when the binary is allowlisted. Examples: git push, git reset --hard, anything against a production host, and any command the model is retrying after a previous failure. The tool returns "awaiting approval" to the model, the runtime prompts the user, and only runs the command if the user confirms. This is the single biggest safety lever in the whole system.

Key takeaways

  1. A naive subprocess.run bash tool will eventually run a command that damages the host. You fix this with layered defenses, not by trusting the model.
  2. Tokenize commands with shlex.split and pass a list to subprocess.run. Never use shell=True in a tool the model controls.
  3. Use an allowlist, never a denylist. Short, deliberate, and extended only when a task requires it.
  4. Cap output, tier timeouts, and guard file paths. Each layer catches a different failure mode and all 3 are small.
  5. Require human approval for destructive invocations even inside the allowlist. git push and git reset --hard are the first 2 to gate.
  6. To see the bash tool wired into a full coding agent with read, edit, and rails, walk through the Build Your Own Coding Agent course, or start with the AI Agents Fundamentals primer.

For the official Python subprocess security guidance, see the subprocess documentation's security considerations. Every rule in this post maps onto something the subprocess docs explicitly warn about.

Share this post

Continue Reading

Weekly Bytes of AI

Technical deep-dives for engineers building production AI systems.

Architecture patterns, system design, cost optimization, and real-world case studies. No fluff, just engineering insights.

Unsubscribe anytime. We respect your inbox.

Ready to go deeper?

Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.