Harness engineeringLesson 05

Permissions and Sandboxing

A prompt is not a security boundary. Learn how to give an agent exactly the authority a task needs — and how to enforce it in code, not in a sentence.

Advanced26 min readUpdated November 2026Try approval modes in a mission →

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.

Figure 5.1Tool permission ladder
Tool calls and the permission layerA model proposes a tool call. The harness validates the schema, checks policy, executes inside a sandbox, and returns a structured result. Approval modes decide whether an action runs automatically, asks a human, or is denied.A tool call is a contract with a security boundaryPROPOSE → VALIDATE → AUTHORIZE → EXECUTE → OBSERVEMODELproposesname + argumentsSCHEMAvalidatestypes, required fieldsPOLICYauthorizesscope, approval, denySANDBOXexecutesfiles, network, secretsRESULTobservesdata or actionable errorPERMISSION LADDERAUTOread-only, scoped, reversibleASKedits, shell, network, spendDENYsecrets, destructive, unauditedApprovals are not a UX detail. They are the point where autonomy meets authority.THE ERROR MESSAGE IS PART OF THE TOOL“Invalid input” invites a loop. “Expected JSON with field email; received form data” lets the agent recover.MCP standardizes discovery and invocation. It does not make a tool safe by itself.
Authority is layered: tool schema, policy check, sandbox, credentials, and audit. Each layer should be sufficient to stop the failures the layer below cannot see.

Authority is a tuple, not a mode

“Read-only” and “full access” are too coarse. A useful grant specifies five things:

DimensionQuestionExample
IdentityWho is acting?tenant acme, user dev@acme.com, agent coder-7
CapabilityWhat operation?read, write, execute, network, spend
ResourceOn what?/workspace/repo, database analytics, host api.github.com
ConstraintsUnder what limits?max 2 MB, 30 s timeout, no secrets, 10 requests/min
LifetimeFor 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:

  1. Read-only, sandboxed. Read files inside the workspace, list directories, search. No network, no secrets.
  2. Workspace write. Edit files under a canonicalized root. Diff capture on every change. No execution.
  3. Workspace execute. Run commands inside a container with a read-only base image and a writable workspace volume. Resource limits and timeouts apply.
  4. Network egress. Reach an allowlisted set of hosts through a proxy. Log every request. No raw internet by default.
  5. External side effects. Send messages, create issues, deploy, charge. Require approval or an idempotency key, and scope credentials to the action.
  6. 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 classExamplesDefault
Readread file, search, listauto
Local reversible writeedit file in workspace, create branchauto with diff capture
Local irreversibledelete files, rewrite git historyask
External reversiblecreate draft PR, post to a test channelask once per task
External irreversiblesend email, charge card, deployask every time
Productionmutate data, rotate credentialsdeny; 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

  1. access to private data,
  2. exposure to untrusted content, and
  3. 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 -c allows ;, &&, $(), pipes and redirection. Use execFile with an argv array, or a parser with an allowlist.
  • Argument confusion. A tool that accepts --output /etc/passwd can 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.

Sources and further reading

One question before you go

retrieval check
Which is a real security boundary for an agent?
Why: Instructions can be overridden by prompt injection. Enforceable boundaries live in the sandbox, credentials and policy layer.
Type at least two characters. Search is indexed after the production build.
Lessons · labs · glossary↑↓ to browse · esc to close