Coding Agent Permissions and Sandboxing

Consider a hypothetical team that assigns a coding agent a routine job: make a failing test pass. The agent runs in a developer’s checkout with a shell, an editor, and a package manager. The shell inherits the developer’s environment, which includes a package-registry token. The network is open. The issue text the agent was asked to read contains a sentence, planted by someone who can file issues, telling the agent to print its environment and send it to an address the sentence supplies. Nothing in this scenario is exotic. Every ingredient is a default of some agent setup, and the scenario is illustrative, not a report of a real incident.

Whether the agent follows the planted sentence is not the question this article answers, because none of the sources cited below describes a technique that guarantees it will not. The question is what the surrounding system lets the agent do if it does follow it. If the shell can read the token, the network can carry it out, and nothing records or interrupts the sequence, a text-processing mistake becomes a credential leak. If the shell never held the token, the network refuses the destination, and the attempt is recorded and surfaced, the same mistake costs nothing. The difference lies entirely in what the platform around the model enforces.

This article is about building that enforcement. It covers five things in order: how to isolate the place where an agent’s actions execute, how to handle the credentials the agent can reach, how to define tool permissions that hold up against how agents actually behave, how to design approval and audit so they provide evidence instead of ritual, and how to bound the damage and recover when something goes wrong anyway. It assumes you have read Testing and Reviewing AI-Generated Code in CI and understand that generated changes are verified by tests, static analysis, and review before they merge. That article is about the change after it exists. This one is about what the agent could do while producing it, which no review of the final diff can observe. It also assumes general programming knowledge of processes, file permissions, and the shell.

Examples use Claude Code and the GitHub Copilot cloud agent as documented, concrete systems, because their permission and isolation features are publicly specified. Feature names, flags, and defaults change between releases. The statements about them below come from their documentation as read while writing this article; check your installed version before relying on any specific setting. The principles do not depend on a vendor. The code examples are small programs written for this article. Where an output is shown, it comes from running that code locally, with the environment stated.

What changes when a model can act

An AI agent, in the sense used here, is a loop: a model chooses an action, the surrounding program executes it, and the result is fed back for the next choice. A chat assistant produces text that a human then decides what to do with. An agent turns some of that text into actions without a human in each step. The security consequence is that the model’s output stops being advice and becomes input to an executor, so anything that can influence the model can influence the executor.

The model is influenced by more than the developer’s prompt. It reads files, issue text, web pages, test output, and dependency documentation, and it cannot reliably tell which of those words are instructions from the person it works for. That is the mechanism behind prompt injection. Simon Willison’s “lethal trifecta” formulation states the dangerous combination compactly: an agent that has access to private data, is exposed to untrusted content, and can communicate externally can be steered into sending the data out. He argues that the practical response is to avoid holding all three at once, and he is explicit that a filter claiming to catch most attacks is not adequate when an attack that gets through is costly. A coding agent in a developer checkout holds all three by default: the repository and the developer’s files are private data, issues and web pages are untrusted content, and a shell with network access is an external channel.

OWASP’s 2025 entry on excessive agency gives the engineering vocabulary for what to remove. It traces damaging actions to three root causes: excessive functionality, meaning the agent has tools it does not need; excessive permissions, meaning the tools it has can do more than the task requires; and excessive autonomy, meaning high-impact actions run without a human check. Its recommended measures follow from those causes: keep the set of tools minimal, limit each tool’s scope, apply least privilege to the systems the tools call, require human approval for high-impact actions, avoid open-ended tools such as a general shell runner where a purpose-specific function would do, and enforce authorization in the downstream system rather than trusting the model’s judgment. OWASP adds that monitoring and rate limiting can reduce damage but do not remove the underlying weakness.

These three causes map to different control layers, and the layers fail independently, which is why the rest of this article treats them separately.

Root cause (OWASP)Question to askControl layerSection
Excessive functionalityDoes the agent have a tool or capability this task never needs?Tool set and capability designTool permissions
Excessive permissionsWhat can each tool, and the identity behind it, actually reach?Execution isolation and credentialsExecution isolation; Credentials
Excessive autonomyWhich actions run with no human in the loop, and what can they not undo?Approval, audit, limits, recoveryApproval and audit; Limits and recovery

One principle runs through all of it and is stated plainly in Claude Code’s permissions documentation: permission rules are enforced by the harness, not by the model. Instructions in a prompt or in a project instruction file shape what the model tries to do, but they do not change what the harness allows. Asking an agent in prose to avoid a directory is a request. A rule or an operating-system restriction is a control. The least privilege principle applies to the agent’s identity exactly as it applies to a service account, and the reasoning in the glossary entry carries over: an assistant that may read an order is not thereby allowed to issue a refund, and a prompt that says otherwise changes nothing about authorization.

Execution isolation: where the actions run

Two different kinds of boundary

There are two places to constrain an agent, and they behave differently. The first is a permission gate inside the agent harness. Before the agent runs a tool, the harness evaluates the request, usually by examining the tool name and its arguments, and allows, denies, or asks a human. The second is an isolation boundary enforced by the operating system or a hypervisor around the processes that actually run. The gate reasons about what the agent asked for. The boundary restricts what a running process can touch regardless of what it asked for.

The distinction matters because a gate has to understand the request, and understanding arbitrary shell commands is hard. The Claude Code documentation says this directly about its own Bash rules: a deny or ask rule covers the invocation the model usually produces and is not a security boundary around the program. Its examples show a rule against curl not stopping a path-qualified invocation or one wrapped in sh -c, and a rule against git push not stopping git -C . push. The documentation points to sandboxing for restrictions that must hold independent of the command text. An isolation boundary has the opposite weakness: it knows nothing about intent, so it can only enforce coarse resource limits such as which paths are writable and which hosts are reachable. A sound design uses both, and puts the hard guarantees in the boundary.

What an OS-level boundary restricts, shown on a small example

To make the boundary concrete, here is a small experiment on macOS that needs no extra software. It uses sandbox-exec, the command-line front end to the Seatbelt sandbox, which Claude Code’s documentation names as what it uses on macOS (on Linux and WSL2 it names bubblewrap). The macOS manual page marks sandbox-exec as deprecated, so treat this as a way to observe the mechanism, not as a recommendation to build on the command. The profile below is hand-written for this article and is not Claude Code’s profile. It denies all network access, denies file writes everywhere except one work directory, and denies reads of a directory named .aws that holds a placeholder file standing in for a credential. A sandbox restricts what code can reach, and the name alone says little about which restrictions are in force, so the profile states them.

(version 1)
(allow default)
(deny network*)
(deny file-write* (require-not (subpath "/path/to/sandbox-demo/work")))
(allow file-write* (literal "/dev/null") (subpath "/dev/fd"))
(deny file-read* (subpath "/path/to/sandbox-demo/home/.aws"))

A probe script then tries four operations and reports what happened. It also reports whether an environment variable with a placeholder value is visible. A short runner script (not listed, since it only sets up the test) starts a harmless listener on the loopback interface, so the network test needs no external host, sets the placeholder environment variable and the listener’s port, and runs the probe twice, once plainly and once as sandbox-exec -f profile.sb python3 probe.py .... The environment was macOS 26.7.1 and Python 3.12, and the inputs were only placeholder files and values. The two runs should differ on exactly the operations the profile denies.

import os, socket, sys
work, home, outside = sys.argv[1:4]
def attempt(name, fn):
    try:
        fn(); print(f"{name:34} allowed")
    except Exception as e:
        print(f"{name:34} blocked ({type(e).__name__}: {getattr(e,'strerror',e)})")
attempt("write inside work dir",        lambda: open(os.path.join(work,"ok.txt"),"w").write("x"))
attempt("write outside work dir",       lambda: open(outside,"w").write("x"))
attempt("read placeholder credential",  lambda: open(os.path.join(home,".aws","credentials")).read())
def conn():
    s=socket.create_connection(("127.0.0.1", int(os.environ["PROBE_PORT"])), timeout=2); s.close()
attempt("connect to local listener",    conn)
print("env FAKE_TOKEN visible:", "FAKE_TOKEN" in os.environ)

Actual output of that run:

== without sandbox
write inside work dir              allowed
write outside work dir             allowed
read placeholder credential        allowed
connect to local listener          allowed
env FAKE_TOKEN visible: True

== with Seatbelt profile
write inside work dir              allowed
write outside work dir             blocked (PermissionError: Operation not permitted)
read placeholder credential        blocked (PermissionError: Operation not permitted)
connect to local listener          blocked (PermissionError: Operation not permitted)
env FAKE_TOKEN visible: True
 

The sandbox changed three of the five observations. A write outside the work directory, a read of the credential directory, and a loopback connection were each refused with a permission error, while a write inside the work directory still succeeded, which is the check that the boundary is not simply breaking everything. The last line is the one to remember: the environment variable is visible inside the sandbox. Seatbelt restricts system calls such as opening files and connecting sockets. It does not remove data the process already holds in its own memory and environment. Claude Code’s sandboxing documentation makes the same point in its defaults table, listing environment variables as inherited from the parent unless credential settings say otherwise. Isolation of reach and handling of secrets are separate problems, and the Credentials section returns to the second.

The experiment has clear limits. It ran on one macOS version with a hand-written profile, it tested a loopback connection, not an external one, and it tested four operations out of the many a process can attempt. A passing probe shows that these four restrictions held. It does not show that the profile is complete.

What the documented agent sandboxes do and do not cover

Claude Code’s built-in sandbox applies this kind of boundary to the shell commands the agent runs and the processes they start. According to its documentation, the sandbox is off by default and must be turned on. By default sandboxed commands may write to the working directory, a per-user temporary directory, and any directories you added, while protected paths stay write-denied. Reads are broader: most of the machine is readable, including credential files such as the SSH directory and the cloud credentials file, until you add read denials. Network access goes through a proxy on the machine that checks each destination host against an allowlist that starts empty.

Three properties of that design generalize to any agent sandbox you evaluate.

  • The boundary covers only what runs inside it. The documentation states that the built-in file tools, web tools, MCP servers, and hooks run outside the shell sandbox and are governed by permission rules instead. An agent process that edits files through its own editor tool is not constrained by the shell sandbox when it does so. To place those tools behind one boundary, the documentation recommends running the entire agent process inside a container, a virtual machine, or the sandbox runtime.
  • Protected paths prevent self-modification. The sandbox denies writes to the files the agent harness loads configuration and code from, such as its settings files, hook directories, and shell startup files, even inside the writable directory. The documentation gives the reason: a command that could edit those files could grant itself permissions or add a hook that later runs outside the sandbox. A boundary that the confined process can rewrite is not a boundary.
  • Escape hatches are part of the threat model. The documentation describes an unsandboxed retry: when a command fails inside the sandbox, the agent may ask to run it outside. Who approves that depends on the permission mode, and an allow rule that matches the command also approves the retry. A strict mode that disables the retry exists. A sandbox with an escape hatch that the agent can request, and that a broad allow rule can silently grant, is only as strong as the approval around the hatch.

The same documentation lists limitations that apply to the network layer. The proxy decides from the hostname the client supplies and by default does not inspect encrypted traffic, so allowing a broad domain such as a code-hosting site can create an exfiltration path, and techniques such as domain fronting may let code reach hosts outside the allowlist. Allowing access to a Unix socket for a container daemon effectively grants control of the host. Overly broad write permissions to directories on the executable search path or to shell startup files can lead to code execution in other security contexts. These are not criticisms of one product. They are the places where any allowlist-based sandbox is weakest, and they tell you which allowlist entries to scrutinize.

Containers, user-space kernels, and virtual machines

When the whole agent process must be confined, the usual choice is a container, a virtual machine, or something between them. The earlier article on container images, isolation, and resource boundaries treats the mechanics of containers, so this section only states what they imply for agents. Containers share the host kernel, so a kernel vulnerability is a path out, and what a container can do depends on its configuration. Docker’s documentation says containers are unprivileged by default, grant a default list of Linux capabilities, and let you reduce the list with --cap-drop, run as a chosen user with --user, and bound memory with --memory. The Kubernetes Pod Security Standards express a hardened baseline: the Restricted profile disallows privilege escalation, requires running as a non-root user, requires dropping all capabilities except possibly binding low ports, and requires a seccomp profile of the runtime default or a locally defined one.

Two stronger options reduce the shared-kernel exposure. gVisor interposes a user-space kernel written in Go that handles system calls itself instead of passing them to the host kernel; its documentation states the trade-offs as higher per-call overhead and reduced compatibility, since not every Linux call or pseudo-file is implemented. A virtual machine, including a microVM, gives the workload its own kernel and virtualized hardware. Claude Code’s sandbox environments documentation describes a dedicated virtual machine as the strongest separation among the options it lists and recommends it for untrusted code or when policy requires kernel-level separation. It also describes a development container with a default-deny firewall as a setup suitable for unattended runs.

The documentation gives one more rule that deserves emphasis. A mode that skips permission prompts should only be used inside an isolation boundary, because with no prompts the boundary is the only remaining control. It also warns that isolation does not eliminate risk: any setup that allows network egress can still leak data the agent can read, and any setup that mounts the project writable can still modify that code. This is the correct frame for choosing among the options. You are not choosing the most secure one in the abstract. You are matching the boundary to what the agent must read, write, and reach, and accepting the residual exposure that remains inside it.

LayerEnforced byWhat it can seeWhat it does not cover
Permission gate in the harnessThe agent programTool name and arguments as the agent wrote themThe same program invoked another way; anything a permitted tool does internally
OS sandbox on commandsKernel (Seatbelt, bubblewrap)System calls of the command and its childrenTools that run in the harness process; data already in the environment; allowed destinations
ContainerShared host kernel plus configurationEverything inside the containerKernel vulnerabilities; whatever you mount or pass in; egress you allow
User-space kernel (gVisor) or VMSeparate kernel or hypervisorEverything inside the guestEgress you allow; data you place inside; compatibility and overhead costs

For a platform team, the practical decision is a tier. Disposable, network-restricted environments suit tasks that run model-written code or install dependencies, because installation executes arbitrary scripts; the supply chain controls that govern what your pipeline may fetch apply with the same force to an agent that can run a package manager. A lighter per-command sandbox on a developer machine suits interactive work where a human watches each step. Both choices are recommendations from the reasoning above, not measured claims about relative safety.

Credentials: what the agent can hold

Isolation limits what a process can reach. Credentials decide what a reached system will let the process do. The most damaging agent failures combine the two: a process that can reach an API and holds a credential with wide authority. The first step in managing this is an inventory, because the credentials an agent can reach are usually more numerous than the one the designer intended to give it.

Where the credential livesHow the agent can reach itMitigation
Environment variables of the harnessChild processes inherit them. The probe above showed the variable visible inside a sandbox.Start the agent with an explicit, minimal environment instead of the developer’s
Files in the home directory (SSH keys, cloud credential files, registry configs)Readable by default in the documented Claude Code sandbox unless deniedRun as a dedicated user or container with an empty home; deny reads; do not mount them
Git remote configuration and credential helpersA push or fetch uses whatever the helper suppliesGive the agent its own remote credential limited to one repository and branch pattern
Tokens issued to CI jobsPresent in the job environment where agent-written code or tests may runScope the job’s token to the minimum; see the CI trust-boundary discussion
Credentials held by tool servers (MCP or similar)Tool servers often run outside any command sandbox with the user’s full accessTreat each server as a privileged process; confine it separately; prefer narrow tools

Keeping secrets out of the process

The simplest control is to not pass the secret. The secrets management entry states the rule that configuration should reference a secret and never contain one, with the qualification that any component that reads the value during a run holds the plaintext. The same qualification applies to agents: a command that is handed a token can print it, write it to a log, or send it. A short check shows the difference between an inherited and a scrubbed environment. Using the same placeholder value as the probe, the first command below inherits the variable and the second starts the process with an empty environment plus only what it needs.

FAKE_TOKEN=PLACEHOLDER-TOKEN python3 -c "import os;print('inherited:', 'FAKE_TOKEN' in os.environ)"
FAKE_TOKEN=PLACEHOLDER-TOKEN env -i PATH="$PATH" HOME=/nonexistent python3 -c "import os;print('scrubbed:', 'FAKE_TOKEN' in os.environ)"
inherited: True
scrubbed: False

Building the environment from an allowlist is safer than deleting known-bad names from the inherited one, because a deny-list misses the variable nobody thought to list. Claude Code’s documentation offers both styles: credential settings that deny named files and variables for sandboxed commands, and a separate setting to scrub credentials from all subprocesses. It also notes that there is no built-in list, so only the entries you configure are protected. A mask mode substitutes a placeholder that the sandbox proxy replaces with the real value on outbound requests to allowed hosts, so the command never holds the real credential yet its requests still authenticate. That is a useful pattern in general: place the secret in a component that is outside the agent’s reach and lets the agent use the credential without learning it. Claude Code’s cloud sessions are documented as using a separate proxy that holds the repository token outside the sandbox and issues scoped credentials inside it.

Give the agent its own identity, with narrow and short-lived authority

An agent that borrows a developer’s identity inherits that developer’s access, including access the task never needs, and its actions in audit records look like the developer’s. The alternative is a distinct identity for the agent. Its permissions should be the task’s, not a person’s. This is standing privilege reasoning applied to automation: a credential that exists between tasks is available to misuse between tasks, so prefer credentials issued for a task and expiring soon after. The article on cloud accounts, identity, and landing zones addresses account and identity structure, and separate accounts and roles are one way to give an agent an identity that cannot reach production.

GitHub’s documentation illustrates the pattern for CI. It creates a unique token for each workflow job, scoped to the repository that contains the workflow. For GitHub-hosted runners the token’s maximum lifetime is six hours, matching the job limit, and the documentation advises granting the token the least access required, with a workflow-level permissions setting to restrict it. For cloud access, OpenID Connect lets a job present a signed identity token and receive a short-lived cloud credential after the provider checks conditions on claims such as the repository, branch, and environment. The documented benefits are that no long-lived cloud secret is stored and each credential expires with the job. An agent that runs inside such a job gets credentials of that shape: bounded by the job’s permissions and lifetime. Treat the bearer property seriously, since an access token authorizes whoever presents it. Anything that can read the token can use it until it expires, so lifetime and scope together bound the damage.

Several design choices follow, stated as recommendations. Issue the agent a separate identity per environment, never one shared across them. Scope repository credentials to the single repository and, where the platform supports it, to branch patterns the agent may write. Withhold any credential for deployment, production data, or secret stores unless the task is specifically to operate on them, and in that case isolate it in its own tier with its own approval. Write down the revocation procedure and test that it works, because the ability to revoke every credential an agent held is part of its blast radius, and you cannot assess a list you have not enumerated. The same applies to the possibility that the agent’s own credentials, such as its model API key, are reachable from inside its sandbox.

Tool permissions: what the agent may ask for

Prefer narrow tools to a general shell

OWASP’s guidance to avoid open-ended tools has a direct engineering reading. A tool called run_tests that takes no free-form arguments, or a tool that opens a pull request against a fixed repository, has a small and reviewable behavior. A tool called run_shell can do anything the account can do, so every constraint on it must be reconstructed outside it. A shell is often unavoidable for coding work, since builds and tests are shell commands. The point is that each capability you can move from the shell into a narrow tool is one you can restrict precisely, and that the remaining shell should sit inside the strongest boundary you can afford.

Allow, ask, deny, and how matching goes wrong

Most harnesses offer three outcomes per tool request. Claude Code documents them as allow rules, ask rules, and deny rules, evaluated in the order deny, then ask, then allow, with the first match deciding. A broad deny rule therefore cannot be overridden by a narrower allow rule, and a matching ask rule still prompts even when an allow rule also matches. This ordering is the right default for a security policy: the most restrictive statement wins, so a later, more convenient allow rule cannot silently weaken an earlier prohibition.

The difficulty is in what a rule matches. Pattern matching on shell command text fails in predictable ways, and two failure modes are worth examining because they are not obvious. Git is the standard example. Its subcommand log sounds read-only, and an allowlist that permits commands beginning with git log or git status sounds safe. Two small experiments, run in a throwaway repository with Git 2.55.0, show why it is not. In the first, git log with the --output option writes a file. In the second, git status with a configuration override supplied through -c runs a program named by the override. The program here is a script that only creates a marker file.

# in a throwaway repository; hook.sh (shown below) only creates a marker file
git log --oneline --output=written-by-log.txt
ls
cat written-by-log.txt

git -c core.fsmonitor="<path-to>/hook.sh" status --short
ls -a | grep marker
#!/bin/sh
touch "<repo>/fsmonitor-ran.marker"
a.txt
written-by-log.txt
91771f9 init
warning: Empty last update token.
?? fsmonitor-ran.marker
?? written-by-log.txt
fsmonitor-ran.marker

Both results come from a real run. The first command, which looks like reading history, created written-by-log.txt because --output redirects the log to a file. The second, which looks like inspecting status, executed the script, and the marker file proves it ran; the warning about an empty update token appears because the script produces none of the output Git expects from a file-system monitor. Neither command is a vulnerability in Git, whose documented options behave as intended. They show that the name of a subcommand does not bound what a command line can do, and that a rule matching the text git status by prefix grants whatever the options add. The Claude Code documentation describes the same family of problems for deny rules and lists related cases, including wrapper programs and invocation by absolute path.

Allowlist by parsed arguments, not by prefix

The sturdier approach inverts the logic. Instead of listing what to block, parse the command into an argument vector without invoking a shell, then allow a specific program, a specific subcommand, and only flags from a short list. Anything not listed is denied, and the command is executed directly from the parsed vector, never through a shell that could reinterpret metacharacters. The program below is a minimal sketch written for this article. It is a teaching example for the matching logic, not a complete command gate: it does not execute anything, it ignores values for flags beyond a digit check, and a real gate would also constrain paths and environment.

import shlex

# Policy: program -> subcommand -> flags allowed. Anything not listed is denied.
POLICY = {
    "git": {
        "status": {"--short", "--branch"},
        "log":    {"--oneline", "--stat", "-n"},
        "diff":   {"--stat", "--name-only"},
    },
}

def naive(cmd: str) -> bool:
    return cmd.startswith("git status") or cmd.startswith("git log")

def decide(cmd: str):
    try:
        argv = shlex.split(cmd)
    except ValueError:
        return False, "unparseable"
    if len(argv) < 2 or argv[0] not in POLICY:
        return False, "program not allowed"          # no path-qualified names, no wrappers
    sub = POLICY[argv[0]].get(argv[1])
    if sub is None:
        return False, "subcommand not allowed"       # blocks 'git -c ...' and 'git -C ...'
    for a in argv[2:]:
        if a.isdigit():                              # value for -n
            continue
        if a not in sub:
            return False, f"flag not allowed: {a}"
    return True, "ok"                                # execute argv directly, never through a shell

tests = [
    "git status --short",
    "git log --oneline -n 5",
    "git log --oneline --output=notes.txt",
    "git -c core.fsmonitor=./hook.sh status",
    "git status; echo extra",
    "/usr/bin/git status",
    "git status\"",
]
print(f"{'command':45} {'naive':6} gate")
for c in tests:
    ok, why = decide(c)
    print(f"{c:45} {str(naive(c)):6} {ok} ({why})")
command                                       naive  gate
git status --short                            True   True (ok)
git log --oneline -n 5                        True   True (ok)
git log --oneline --output=notes.txt          True   False (flag not allowed: --output=notes.txt)
git -c core.fsmonitor=./hook.sh status        False  False (subcommand not allowed)
git status; echo extra                        True   False (subcommand not allowed)
/usr/bin/git status                           False  False (program not allowed)
git status"                                   True   False (unparseable)

Read the output column by column. The naive prefix check approves git log --oneline --output=notes.txt and git status; echo extra, and the gate refuses both, the first because the flag is not listed and the second because the token status; is not a listed subcommand. The naive check also happens to refuse the -c form, but only because that string does not begin with the permitted prefix. A deny-list written as a prefix match on the dangerous form would approve the reordered variants. The gate refuses it by structure. The absolute-path program and the unbalanced quote are refused because no rule names them, which is the safe default for the unknown. The gate has a cost: every legitimate flag must be added deliberately, so it needs maintenance, and an allowlist that grows without review drifts back toward a deny-list. This cost is the price of a policy you can reason about.

File paths: resolve before you compare

File tools have the same problem in a different form. A rule that confines the agent to a repository directory is only as good as the comparison that decides whether a requested path is inside it. A string prefix comparison is wrong in three common ways, shown by the experiment below. A path with .. components can climb out of the directory while still starting with its name. A sibling directory whose name begins with the repository’s name shares the prefix. A symbolic link inside the repository can point to a file outside it. The resolved comparison resolves the requested path against the real filesystem first, following symbolic links and removing .., then checks that the repository’s resolved path is the target or one of its ancestors.

import os, tempfile
from pathlib import Path

def naive_allowed(root: str, requested: str) -> bool:
    return requested.startswith(root)

def contained(root: str, requested: str) -> bool:
    root_real = Path(root).resolve(strict=True)
    target = (Path(root) / requested).resolve(strict=False)   # follows symlinks and ".."
    return target == root_real or root_real in target.parents

base = Path(tempfile.mkdtemp(prefix="agentdemo-")).resolve()
repo = base / "repo"; repo.mkdir()
(repo / "src").mkdir()
(base / "repo-secrets").mkdir()
(base / "repo-secrets" / "token.txt").write_text("PLACEHOLDER-NOT-A-REAL-SECRET\n")
(base / "outside.txt").write_text("outside\n")
os.symlink(base / "outside.txt", repo / "src" / "link-to-outside")

cases = {
    "plain file in repo":      str(repo / "src" / "main.py"),
    "dotdot escape":           str(repo / ".." / "outside.txt"),
    "sibling with same prefix": str(base / "repo-secrets" / "token.txt"),
    "symlink inside repo":     str(repo / "src" / "link-to-outside"),
}
print(f"{'case':28} {'naive':6} {'resolved':8}")
for name, p in cases.items():
    print(f"{name:28} {str(naive_allowed(str(repo), p)):6} {str(contained(str(repo), p)):8}")
case                         naive  resolved
plain file in repo           True   True    
dotdot escape                True   False   
sibling with same prefix     True   False   
symlink inside repo          True   False   

All four requests pass the naive check. Only the first passes the resolved check, which is the correct answer. The sibling case contains a placeholder token file, which shows why the prefix mistake matters: a sibling directory named after the project often holds exactly the material you want kept out. The Claude Code documentation describes the same defense: for a path reached through a symbolic link, the permission check covers both the requested path and the resolved target, allow rules apply only when both match, and deny rules apply when either matches. The experiment has a limit: resolving a path and then opening it are two steps, and a link can change between them. The Claude Code documentation says its tools re-confirm, when they open an approved file, that the path still resolves to the location the check approved. Defenses against that race belong in the code that opens the file or in an OS-level boundary, which is another reason to prefer the boundary for hard guarantees.

Classify actions by consequence

A policy is easier to maintain when it is organized by the consequence of the action instead of by tool name. The table below is a recommended starting classification, not a standard. The defaults assume an isolated environment for the first rows.

Action classExampleSuggested defaultReason
Read inside the task scopeOpen files in the repository, search codeAllowLow consequence if the scope is correct and secrets are absent
Write inside the task scopeEdit files in the working branchAllow in a disposable workspace; ask otherwiseReversible through version control, if the workspace is isolated
Run build and test commandsProject test runner, linterAllow in a sandbox with restricted network; ask otherwiseExecutes code the agent wrote, so containment matters more than approval
Change the agent’s own configurationEdit hook, permission, or tool-server filesDenyA permission boundary the agent can edit is not a boundary
Network egress to new destinationsFetch a URL, install a packageDeny by default; allow named hostsExfiltration channel and supply chain entry point
Publish to shared statePush a branch, open a pull requestAllow only to an agent-owned branch namespaceVisible to others but reversible; protected branches stay protected by the server
Irreversible or external effectsDelete remote branches, send messages, call production APIs, spend moneyDeny, or ask with the evidence shownNot undone by restoring files

Server-side controls belong in the same classification because they work even when the client is misconfigured. GitHub’s documentation for its cloud agent describes several: only users with write access can trigger it, it pushes only to its own prefixed branch or to the pull request’s branch, existing branch protections and required checks still apply, it cannot mark its pull requests as ready for review or approve or merge them, and workflows do not run on its changes until a user with write access approves them. The last point connects to the CI trust-boundary article: tests an agent writes run with the pipeline’s credentials, so withholding automatic execution until a human looks is a control on the path from agent output to a privileged runner.

Approval and audit

What approval is for

Approval is a control for actions that are high-impact and that the system cannot cheaply reverse. It is a poor control for frequent low-impact actions. A person asked to approve hundreds of routine commands stops reading them, and approval then records agreement without examining anything. The automation bias entry describes how reliance grows with volume, time pressure, and a good track record, and notes that an approval log shows decisions made, not effective review. The design consequence is to spend human attention on a short list of consequential actions and to automate the rest with isolation and limits, instead of prompting for everything.

Three properties make an approval meaningful. It shows the evidence needed to decide, which means the exact command or diff, the target, and the credential it will use, not a summary written by the agent. It is requested by the harness rather than by the model, so a model cannot talk its way past it. And it is answered by an identity that is not the agent’s own. The Copilot agent’s rule that it cannot approve or merge its own pull requests is this property applied at the review step. When approval fires often, treat that as a signal to examine the policy: either the rule is too tight, so make it an allow inside a stronger boundary, or the action is consequential and belongs in a narrow tool.

What to record, and where

An audit log supports investigation, and its value depends on coverage, correct time information, and protection against alteration. For agents, each record should capture the session and agent identity, the tool and its arguments as the harness saw them, the decision and which rule produced it, who approved if anyone did, and the outcome. The request and its outcome are separate events: a record that a push was requested does not show that it succeeded. Log denials as well as permitted actions, because a denied attempt is evidence of what the model tried, and a pattern of denied attempts after reading a particular file is how you find an injection.

GitHub’s documentation describes the Copilot agent’s commits as signed, attributed to both the agent and the person who requested the work, and linked to session logs for administrators. Whatever platform you use, check three things: that agent actions are attributable to the agent identity and the requesting human separately, that the log is stored where the agent cannot write, and that the log covers denied actions.

Protection against alteration deserves a concrete demonstration, since an agent with write access to its own log can rewrite its history. The sketch below, also written for this article, chains each record to the previous record’s hash. Altering a record breaks every later hash, and the chain check detects it. A second test removes the last record.

import hashlib, json, os, tempfile

def append(path, record, prev_hash):
    body = json.dumps(record, sort_keys=True)
    h = hashlib.sha256((prev_hash + body).encode()).hexdigest()
    with open(path, "a") as f:
        f.write(json.dumps({"rec": record, "prev": prev_hash, "hash": h}) + "\n")
    return h

def verify(path):
    prev = "0" * 64
    for i, line in enumerate(open(path)):
        e = json.loads(line)
        body = json.dumps(e["rec"], sort_keys=True)
        if e["prev"] != prev or hashlib.sha256((prev + body).encode()).hexdigest() != e["hash"]:
            return f"broken at line {i}"
        prev = e["hash"]
    return f"ok, head={prev[:12]}"

p = os.path.join(tempfile.mkdtemp(), "audit.jsonl")
h = "0" * 64
events = [
    {"seq": 1, "tool": "read_file",  "arg": "src/app.py",       "decision": "allow"},
    {"seq": 2, "tool": "run_tests",  "arg": "pytest -q",        "decision": "allow"},
    {"seq": 3, "tool": "git_push",   "arg": "origin agent/fix", "decision": "ask:approved"},
    {"seq": 4, "tool": "run_shell",  "arg": "curl example.test","decision": "deny"},
]
for e in events:
    h = append(p, e, h)
print("verify untouched:", verify(p))

lines = open(p).read().splitlines()
e = json.loads(lines[3]); e["rec"]["decision"] = "allow"          # rewrite the denial
lines[3] = json.dumps(e); open(p, "w").write("\n".join(lines) + "\n")
print("verify after editing entry 4:", verify(p))

lines = open(p).read().splitlines()
open(p, "w").write("\n".join(lines[:3]) + "\n")                    # truncate the tail
print("verify after truncating last entry:", verify(p), "(head hash differs from the one stored elsewhere:", h[:12] + ")")
verify untouched: ok, head=8feab1f50855
verify after editing entry 4: broken at line 3
verify after truncating last entry: ok, head=1462d582fca7 (head hash differs from the one stored elsewhere: 8feab1f50855)

The experiment shows a property and a limit. Editing the fourth entry to turn a denial into an allow is detected, because the stored hash no longer matches. Truncating the final entry is not detected by the chain alone: the remaining three entries still form a valid chain, and the check reports success. The only difference is that the head hash differs from the one recorded elsewhere. A hash chain therefore needs the latest head hash stored outside the agent’s reach, for example in a separate logging service, or the log must be shipped off the machine as it is written. The sketch is not a complete audit design. It is meant to show why ‘append-only’ must be enforced by something other than the writer.

Limits and recovery

Bound the run before it starts

Even a well-confined agent can run too long, loop on a failing step, or consume resources. Limits convert an unbounded failure into a bounded one. Claude Code’s command-line reference documents a maximum number of agentic turns and a maximum spend in dollars for non-interactive runs, and states that both apply only in that mode, with the turn limit ending the run with an error when reached. For the execution environment, set a wall-clock timeout, a memory limit (Docker documents --memory), and a limit on concurrent processes where your runtime supports one. Rate limits on external calls bound the harm of a loop that reaches a real service. These limits do not prevent a harmful action. They bound its repetition and cost, which OWASP also notes about monitoring and rate limiting.

The scope of a failure, usually called its blast radius, is set by what the failed component shares with others. For an agent, the shared things are the identity and its permissions, the mounted directories, and the network. Per-task environments and per-task credentials make the radius a single task. A long-lived shared agent environment with a broad token makes it the whole team.

What can be undone, and what cannot

Recovery depends on the kind of effect. Changes to files in a version-controlled working tree are recoverable: discard the branch or worktree, or revert commits. A disposable workspace makes this routine, since you can delete the whole environment. Claude Code’s checkpointing feature tracks edits made through its file-editing tools and can rewind them, but its documentation states the limits clearly. Files changed by shell commands such as remove, move, or copy are not tracked. Edits by most subagents are not restored, symbolic and hard-linked files are skipped, and checkpoints are described as session-level recovery that does not replace version control. A recovery plan that depends on a convenience feature without knowing these limits will fail in the cases that matter.

Effects outside the workspace are different. A pushed branch can be deleted, but anyone who fetched it has seen it. A message that was sent, a request that was accepted by an external API, or a deployment that was triggered cannot be taken back by restoring files. The rollback entry makes the same point for deployments: rollback does not unsend a message or reverse an accepted order, and past actions need separate investigation and authorized compensating steps. This is why the action classification above puts irreversible and external effects in the strictest class. It also means a recovery runbook for an agent incident has two parts: restoring the workspace, which is fast, and establishing what left the workspace, which depends on the audit log and on knowing which credentials were reachable.

The credential part of the runbook deserves its own steps: revoke or rotate every credential the session could reach, not only the one you suspect, check the provider’s logs for use of each between the incident window and revocation, and review whether any change the agent made could itself persist access, such as a modified workflow file or a hook. Practicing this once on a harmless placeholder is worth more than a written plan.

What this design does not solve

Four limits remain after all of the controls above. First, prompt injection is not eliminated. The controls bound what a successfully steered agent can do, and the lethal-trifecta framing suggests removing one of the three conditions where possible, for example by giving a task that reads untrusted content no external channel. Second, an allowlisted destination can still carry data out, and the sandbox documentation says as much about broad domains. Third, sandboxes have implementation and configuration weaknesses, which is why the documentation warns against treating them as a hard guarantee. Fourth, the agent’s output is still code that people will run, and the controls in this article do nothing about a subtle defect in correctly delivered code, which is the subject of the verification article linked at the start. Treat these controls as risk reduction with known residuals, and write the residuals down.

Putting it together: tiers and acceptance tests

A platform team needs to offer something simpler than the full space of options. One workable approach is a small set of named tiers that bundle isolation, credentials, tools, and approvals. The tiers below are a recommendation derived from the reasoning in this article, not measured results, and the boundaries between them are judgment calls for your own risk tolerance.

TierTask shapeIsolationCredentialsTools and approval
0: read-onlyExplain code, review a diff, answer questionsRead-only mount of a copy; no networkNoneRead and search tools only; no approval needed
1: edit and testFix a bug, add a test, refactorDisposable container or VM; egress limited to named package mirrorsNone, or a read-only repository credentialEdit and test tools allowed; shell parsed by an allowlist; approval for anything off the list
2: propose changeSame as tier 1 plus opening a pull requestAs tier 1Agent identity, one repository, agent-owned branch namespace, short-livedPush to agent branches allowed; no approval or merge by the agent; workflows await human approval
3: operate systemsAnything touching deployment, data, or infrastructureNot recommended as a general defaultPer-task, per-environment, approved individuallyHuman approval for each consequential step with evidence shown

Policies fail silently, so each tier needs acceptance tests that a platform engineer can run and that fail loudly. The tests below correspond to the failure modes in this article. Run them against the actual environment the agent will use, not against documentation, and rerun them after any change to the harness version or the environment image.

  1. Denied read. From inside the environment, try to read a placeholder file placed in a credential location. The read must fail.
  2. Denied write. Try to create a file outside the workspace and a file in a protected location, such as the agent’s own configuration. Both must fail.
  3. Environment. Print the environment from inside the agent’s shell. No credential or unexpected variable may appear.
  4. Network. Attempt connections to a host that is not on the allowlist, by name and by IP address, over HTTP and a non-HTTP protocol. All must fail, and the denial should appear in the log.
  5. Path escape. Create a symbolic link in the workspace to a file outside it and ask the file tool to read and write it. Both must be refused.
  6. Command variants. Run permitted commands with unlisted flags, by absolute path, and chained after a separator. Each must be refused or prompt.
  7. Server-side limits. Attempt to push to a protected branch and to approve or merge the agent’s own pull request with the agent’s credential. Both must fail at the server.
  8. Audit. Confirm each of the above attempts appears in the log with identity, arguments, and decision, and that the agent’s identity cannot modify the log.
  9. Recovery drill. Revoke the agent’s credentials and confirm the next call fails. Time it, and record every place the credential was configured.

These nine checks are inexpensive to automate and give a concrete answer to whether the restrictions described in a design document hold in practice. Once an agent runs inside controls like these, a different question arises: whether adopting it actually improves delivery, which is a measurement problem with its own pitfalls. Measuring an AI Development Tool Rollout takes that question up.

References


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.