Permissions and Sandboxing
The most common agent security mistake is a sentence in the system prompt: “Do not access files outside the repository.” That is a request, not a boundary. A model can be persuaded, confused or simply wrong. Security has to live in the harness.
The prompt is not a boundary
A language model has no concept of a filesystem. It emits tokens. If those tokens happen to form a tool call with path: "../../.ssh/id_rsa", the model did not “access” anything — your tool did. The authority belongs to the process that executes the call.
This distinction is the foundation of agent security:
- The model proposes. It chooses an action and arguments.
- The harness disposes. It validates the action, checks policy, executes it in a sandbox, and records the result.
- The environment enforces. The operating system, container, network policy and credential broker enforce limits the harness cannot bypass.
If the model can talk the harness into executing anything, the model has the authority. Prompt instructions can improve behavior; they cannot be the control.
Authority is a tuple, not a mode
“Read-only” and “full access” are too coarse. A useful grant specifies five things:
| Dimension | Question | Example |
|---|---|---|
| Identity | Who is acting? | tenant acme, user dev@acme.com, agent coder-7 |
| Capability | What operation? | read, write, execute, network, spend |
| Resource | On what? | /workspace/repo, database analytics, host api.github.com |
| Constraints | Under what limits? | max 2 MB, 30 s timeout, no secrets, 10 requests/min |
| Lifetime | For how long? | this tool call, this task, this session |
A coding agent might receive: tenant acme, user dev, capability read+write, resource /workspace/acme-api, no network, no secrets, lifetime one task. A production incident bot might receive: capability read, resource metrics and logs, network to one internal host, lifetime 15 minutes. Neither is “autonomous.” Both are precisely scoped.
The permission ladder
A practical ladder, from least to most dangerous:
- Read-only, sandboxed. Read files inside the workspace, list directories, search. No network, no secrets.
- Workspace write. Edit files under a canonicalized root. Diff capture on every change. No execution.
- Workspace execute. Run commands inside a container with a read-only base image and a writable workspace volume. Resource limits and timeouts apply.
- Network egress. Reach an allowlisted set of hosts through a proxy. Log every request. No raw internet by default.
- External side effects. Send messages, create issues, deploy, charge. Require approval or an idempotency key, and scope credentials to the action.
- Production mutation. Write to production data or infrastructure. Never auto-approved. Require a human, a change ticket and a rollback plan.
Most coding agents need rungs 1–3. Most “do everything” agents are given rung 5 because it is convenient, and then trusted to behave. That is how incidents happen.
Sandboxes are layered
A sandbox is not one thing. It is a stack of controls:
- Process isolation. A separate process or container, not the host shell. The agent should not inherit the harness’s full environment.
- Filesystem isolation. A workspace mount, a read-only root, and explicit bind mounts. Resolve symlinks and
..before checking paths. - Network isolation. No egress by default; allowlist through a proxy. DNS rebinding and metadata endpoints (
169.254.169.254) are classic escapes. - Secret isolation. Do not put long-lived credentials in the container. Use short-lived tokens, a broker, or no credentials at all. Environment variables are readable by every child process.
- Resource limits. CPU, memory, disk, process count, wall-clock time. A fork bomb is a denial of service, not a clever plan.
- Kernel isolation. Containers share a kernel. For hostile code, use a microVM (Firecracker), gVisor, or a separate machine. A container escape is a host compromise.
The right level depends on the threat. Running your own code in your own repo is different from running untrusted code from a pull request. Treat them differently.
Approvals: a dial, not a brake
Approval modes usually range from “ask for everything” to “ask for nothing.” Both extremes are bad:
- Ask for everything causes approval fatigue. Humans click “yes” without reading. The approval becomes a ritual, not a control.
- Ask for nothing turns every model mistake into a production event.
A better design approves by risk class, not by tool:
| Risk class | Examples | Default |
|---|---|---|
| Read | read file, search, list | auto |
| Local reversible write | edit file in workspace, create branch | auto with diff capture |
| Local irreversible | delete files, rewrite git history | ask |
| External reversible | create draft PR, post to a test channel | ask once per task |
| External irreversible | send email, charge card, deploy | ask every time |
| Production | mutate data, rotate credentials | deny; require a separate change process |
The unit of approval matters too. Approving “edit_file” once for a whole session is weaker than approving a specific diff. Approving a shell command string is weaker than approving a capability (“run tests in this container”). Where possible, show the human the effect, not the syntax.
The lethal trifecta
A useful threat model for agents: danger appears when one system combines
- access to private data,
- exposure to untrusted content, and
- the ability to communicate externally.
Any two are manageable. All three together create exfiltration risk. An agent reading a GitHub issue (untrusted), accessing a private repo (private), and able to call an arbitrary URL (external) can be prompt-injected into sending code to an attacker. The injection may be a sentence in a README, a tool description, a web page or a retrieved document.
Mitigations are architectural:
- Remove one leg of the trifecta. No network for the agent that reads untrusted content.
- Treat retrieved content as data, never as instructions. Label it and keep it out of the system-priority region.
- Use an allowlisted egress proxy, not raw network.
- Require human approval for external communication.
- Scan for secrets before any outbound payload.
- Isolate tenants and cache namespaces.
Prompt injection is not solved by telling the model to ignore instructions in documents. The model does not have a reliable privilege boundary between “system text” and “document text.” The harness must.
Policy as code
A policy engine should be small, deterministic and testable. A sketch:
type Action =
| { kind: 'read'; path: string }
| { kind: 'write'; path: string; diff: string }
| { kind: 'exec'; argv: string[]; cwd: string }
| { kind: 'net'; url: string }
| { kind: 'external'; target: string; payload: unknown };
function authorize(action: Action, ctx: Grant): Decision {
switch (action.kind) {
case 'read':
return within(ctx.workspace, action.path) ? allow() : deny('outside workspace');
case 'write':
return within(ctx.workspace, action.path) && ctx.canWrite
? allow({ captureDiff: true })
: deny('write not granted');
case 'exec':
return ctx.canExec && ctx.allowedCommands.matches(action.argv)
? allow({ sandbox: 'workspace', timeoutMs: 30_000 })
: deny('command not allowlisted');
case 'net':
return ctx.egressHosts.includes(new URL(action.url).host)
? allow({ proxy: true })
: deny('host not allowlisted');
case 'external':
return requireApproval(action);
}
}
Three properties matter more than the language:
- Default deny. Unknown actions fail closed.
- Deterministic. The same action and grant produce the same decision.
- Auditable. Every decision is logged with the rule that produced it.
Test the policy like security code: traversal attempts, symlinks, encoded paths, shell metacharacters, redirects, Unicode homographs, missing credentials, expired grants. A policy that has never been attacked is a hypothesis.
Sealing the gaps around tools
Most escapes do not happen in the model. They happen in the seams:
- Shell injection. Passing a model-provided string to
sh -callows;,&&,$(), pipes and redirection. UseexecFilewith an argv array, or a parser with an allowlist. - Argument confusion. A tool that accepts
--output /etc/passwdcan write outside the workspace even if the path argument is validated. Validate every flag, or do not expose raw flags. - Symlink swaps. Check a path, then open it later; an attacker swaps the symlink in between. Open with
O_NOFOLLOW, or operate inside a sandbox where the path cannot change. - Environment leakage. API keys in the parent environment are inherited by every command. Pass a minimal environment, and use short-lived credentials.
- Network redirects. An allowlisted host can redirect to an unallowlisted one. Re-check after redirects, or terminate TLS at the proxy.
- Output injection. A tool result can contain instructions. Treat it as untrusted content, and never let it change policy.
- Time-of-check/time-of-use. Approval of a diff does not guarantee the code that runs is the same diff. Re-verify the hash at execution time.
These are ordinary application-security problems. Agent systems make them urgent because the “user” is a stochastic model that can be manipulated by data.
A deployment checklist
Before giving an agent real authority:
- The sandbox runs as a non-root user with a read-only root filesystem.
- The workspace is a dedicated volume, not the host home directory.
- Network egress is denied by default, then allowlisted.
- Secrets are short-lived, scoped and never in the environment of untrusted code.
- Every write is captured as a diff and attributed to a task and user.
- Destructive and external actions require approval or an idempotency key.
- The policy engine has tests for traversal, injection and redirects.
- Tool outputs are logged, size-limited and treated as untrusted.
- A kill switch cancels in-flight tools, not just the model loop.
- An incident runbook covers credential rotation and workspace quarantine.
Next: state and subagents — how work survives a crash, a compaction and a second agent editing the same file.