Agent Shell Access Safety: Giving a Model a Terminal
A shell tool is the most useful and most dangerous thing you can give an agent. How to grant it without handing over the whole machine.
A shell tool is the single highest-leverage thing you can give a coding agent. It is also the point at which every other safety control you built becomes optional, because a shell can do anything the process running it can do.
The usual reaction is to build a command allowlist and declare the problem solved. Allowlists are worth having, but they are much weaker than they look, and treating them as the whole answer is how teams end up surprised.
Why allowlists leak
An allowlist assumes you can tell what a command will do from its name. For most useful commands you cannot.
Allowing git allows git push, which allows writing to a remote. It also allows configuring a hook that runs arbitrary code on the next commit. Allowing npm allows npm run, which executes whatever the project scripts say. Allowing find allows find . -exec. Nearly every substantial tool contains a way to run something else.
Argument parsing compounds this. If the agent proposes a full command line and you match against the first token, you are matching on the least informative part of the string. Matching the whole line with patterns puts you in the business of writing a shell parser, and shells have a lot of syntax: pipes, substitutions, redirects, quoting, environment prefixes.
The practical conclusion is that an allowlist is a filter for reducing accidental damage, not a boundary. The boundary has to be somewhere the command cannot argue with.
Put the boundary in the environment
The control that actually holds is the one enforced by the operating system rather than by your string matching. Run the shell inside a container or VM with a filesystem mount limited to the working repository, no credentials in the environment, and no network unless the task requires it.
Once that is true, the allowlist changes role. It stops being the thing preventing disaster and becomes the thing preventing wasted turns and confusing failures. That is a much easier job, and it can be permissive.
The environment boundary also fails safe under prompt injection. If a fetched web page or a repository file instructs the model to exfiltrate a token, an allowlist has to anticipate the phrasing of the attack; an environment with no token in it simply has nothing to send. Agent sandboxing covers the isolation options in detail.
Commands worth blocking anyway
Inside a sandbox, a small deny list still earns its place — not for security, but because certain commands waste time or destroy work the sandbox was not protecting.
Block anything that rewrites shared history: force pushes, hard resets that discard uncommitted work, branch deletions. These are recoverable in principle and infuriating in practice, and an agent has no intuition for which uncommitted changes were precious.
Block interactive commands, because they hang. An agent that runs an editor, a pager or a prompt-driven installer produces a tool call that never returns, and the session dies on a timeout with nothing useful in the transcript. Set PAGER=cat, pass non-interactive flags by default, and reject the rest with a clear message.
Block long-lived foreground processes for the same reason. A dev server started in the foreground is indistinguishable from a hang. Provide a separate tool for background processes that returns a handle rather than blocking the loop.
Timeouts are part of the safety model
Every shell invocation needs a wall-clock limit, and the limit needs to kill the process group rather than just the direct child. A test runner that spawns workers will happily leave them running after the parent is killed.
Choose the limit from measured command durations rather than intuition. A limit that is too tight turns a slow test suite into a mysterious failure that the model will then try to work around by weakening the tests.
Return the timeout as a specific, actionable error. Command exceeded 120s and was terminated tells the model something it can act on; a generic failure invites it to run the same command again. Agent timeout strategies covers picking the numbers.
Output is prompt content, not a log
Shell output goes straight into the conversation, where it competes for attention with everything else and is paid for on every subsequent turn.
Truncate deliberately. Keep the head and the tail of long output and mark the elision, because errors cluster at both ends and the middle of a build log is almost never what matters. A cap in the low thousands of tokens is generous for most commands.
Return exit codes explicitly and separately. Models are unreliable at inferring success from prose, and a command that printed warnings but exited zero is routinely misread as a failure worth retrying.
Log the commands, not just the outcomes
Record every command the agent ran, with its arguments, exit code, duration and the turn number. This is the artefact you need when something goes wrong, and it is nearly free to produce.
It is also the input to tightening the policy. After a few weeks you will know which commands the agent actually uses, which ones fail repeatedly because of a missing flag, and which ones nobody expected it to reach for. Agent audit logging covers the record format.
A workable default
Run in a container scoped to the repository, with no credentials and no network by default. Allow a broad set of commands, deny history-rewriting and interactive ones, timeout everything, truncate output, log all of it.
Then add an approval gate for the narrow set of actions that leave the sandbox — pushing, deploying, touching anything shared. That is a short list, which is what makes gating it tolerable rather than exhausting. Approval gates in agents covers where to place them.
Common questions
Is a command allowlist enough to make shell access safe?
No. Most useful commands can execute other code — git hooks, npm scripts, find -exec — so the name of a command tells you little about what it will do. Treat the allowlist as a way to reduce accidents and put the real boundary in the sandbox.
Why do agents hang on shell commands?
Usually an interactive command or a foreground long-lived process. A pager, an editor or a dev server produces a tool call that never returns. Force non-interactive flags, set PAGER=cat, and give background processes their own tool that returns a handle.
How much shell output should a tool return?
A few thousand tokens at most, keeping the head and the tail and marking the elision, since errors cluster at both ends. Return the exit code separately rather than expecting the model to infer success from the text.