You gave your coding agent a shell because it was the path of least resistance, not because it earns the access. Take the shell away entirely and the agent still edits files, still fixes bugs, still argues with you about tabs versus spaces. What breaks is a short, specific list. That list is worth memorizing, because it’s exactly what you should be granting on purpose instead of handing over by default with a shrug and a bash tool definition.
Most of us reach for run_command because it’s one tool that does everything, and “does everything” is also the entire problem.
What Actually Breaks Without a Shell
Be honest about the real dependencies before you get precious about security theater. A handful of things genuinely need a process runtime:
- Running your test suite. Non-negotiable. An agent that can’t run
pytestornpm testcan’t check its own work, which means you’re the CI pipeline now, one keystroke away from merging something that doesn’t compile. - Installing packages and resolving dependencies.
npm install,pip install,cargo add. Real processes, real lockfiles, no way around it. - Build tooling outside the happy path. Native modules, custom compiler flags, a Makefile someone wrote in 2019 and everyone’s afraid to touch.
- Ad-hoc investigation.
git log --oneline, grepping across a monorepo, checking what’s actually bound to port 5432 at 2 AM when the container refuses to start.
That’s the whole list. Everything else the shell does for your agent, it does as a side effect of being one giant hammer, not because the task needed a hammer.
What You Granted By Accident
Here’s the part nobody budgets for. A bash tool isn’t “run tests” plus “install packages.” It’s one indivisible grant that also happens to include reading ~/.ssh/id_ed25519, cat-ing ~/.aws/credentials, curling your internal admin panel because it’s reachable on the LAN, and git push --force to a branch you forgot was protected. You didn’t sit down and decide the agent should be able to do those things. You decided it should run npm test, and the shell came with a few hundred other capabilities stapled to the side, all activated by the same single approval click.
Compare that to file operations, which never needed a process at all. A dedicated read/write/edit tool operates on a defined workspace, an agent can’t accidentally cat a file three directories above where you meant, and every call shows up in your logs as exactly what it is: a read of src/utils/parse.ts, not an opaque shell string you have to mentally parse for shenanigans. Same story for search: a grep tool that takes a pattern and a path is auditable. bash -c "grep -r ... | xargs rm" piped through three commands is a string you’re supposed to catch in a two-second approval prompt, which is a test almost nobody passes consistently.
The Workers Experiment
Someone actually ran this as an accidental experiment rather than a thought exercise. A project ported the portable core of the Pi coding agent, the @earendil-works/pi-agent-core and @earendil-works/pi-ai packages, into Cloudflare Workers and Durable Objects. That’s an environment with genuinely no POSIX filesystem and no process runtime, not a sandboxed version of one. According to the writeup, the virtual workspace lives entirely in a Durable Object’s SQLite, and the file tools (read, write, edit, list, find, grep) operate directly against database rows. The system prompt has to explicitly warn the model that there is no shell and it shouldn’t claim to run one.
The agent loop ported without drama. Model calls, tool execution, the session tree, context compaction, streaming output, all of it worked with zero process runtime underneath. Treat the specifics as one team’s early writeup on a beta surface rather than a settled spec (that package ecosystem is new and still moving), but the shape of the result lines up with the argument here: the loop doesn’t need a shell, it needs tools that do what the shell was doing for the narrow slice of work that actually mattered.
Bash Is a Capability Bundle, Not a Tool
That’s the real point: bash isn’t a tool your agent uses, it’s a capability bundle you can’t read the contents of by looking at the name. A read_file tool tells you what it does. A run_command tool tells you nothing until the string inside it runs, at which point it’s done whatever it was going to do.
Granting the Shell on a Leash
None of this means rip the shell out for anyone with a real test suite, that’s a forklift-shaped overreaction to a couch-sized problem. It means stop granting it ambiently and start granting it scoped.
docker run --rm -it \ --network none \ --read-only \ --tmpfs /tmp:rw,nosuid,mode=1777 \ --tmpfs /workspace:rw,nosuid,uid=1000,gid=1000,mode=0700 \ --cap-drop ALL \ --security-opt no-new-privileges \ -u 1000:1000 \ -v "$(pwd)":/workspace/src:ro \ -w /workspace \ my-agent-runtime--network none means no LAN, no curling your internal services, no phoning anything home. --read-only plus a couple of --tmpfs mounts means the root filesystem can’t be touched and scratch space disappears when the container exits. Set uid/gid/mode on those tmpfs mounts or they arrive owned by root at mode 755 and your non-root agent can’t write to them at all. Mount your actual project read-only and copy files in for the agent to edit if you need it to persist changes, or drop the :ro deliberately when you want write access, as a decision, not a default. Never mount ~/.ssh, ~/.aws, or the Docker socket into that container. If the agent needs the socket to build images, that’s a separate, explicit grant, not something that rides along because you were in a hurry.
On the tool-definition side, the contrast is just as stark:
{ "name": "run_tests", "description": "Runs the project test suite", "parameters": { "path": "string" }}versus
{ "name": "run_command", "description": "Executes a shell command", "parameters": { "command": "string" }}The first one, you can approve on sight, every time, without reading anything past the tool name. The second one requires you to actually parse arbitrary strings under time pressure, which is exactly the security control that fails first.
The question isn’t whether your agent needs a shell. Plenty do, and pretending otherwise is its own kind of overengineering. The question is why it needs your entire shell, with your keys, your network, and your write access to everything, when the job in front of it was “make this test pass.”