What actually contains a CLI agent on your own machine
A hosted assistant answers inside someone else's boundary. A CLI agent doesn't — it runs as you, in your terminal, and every tool call it makes is a real syscall with your uid behind it.
By Piotr Zenon Żabrowski · Published · Updated
The practical difference isn't intelligence, it's authority. A chat window can produce a bad sentence. An agent that can read your repository can call a tool, and a tool call is a syscall with your uid, your environment variables and your network access attached. So the interesting question isn't what the model would do. It's what the process around it is allowed to do.
The instruction channel is the attack surface
An agent also reads text you didn't write: a README, an issue, a changelog, a package description, a tool result, a web page. Prompt injection isn't a failure of the model's judgement. It's what happens when one channel carries both instructions and data — which is why OWASP puts it first on the LLM list (LLM01:2025). Their agentic list splits the consequences into separate entries: goal hijack (ASI01), tool misuse (ASI02), privilege abuse (ASI03), supply chain (ASI04), unexpected code execution (ASI05). For those entries, the named mitigations include execution sandboxes, per-session isolation and egress controls. A sandbox isn't a workaround for weak prompting. It's the control the guidance asks for.
And it isn't theoretical. An instruction planted in a repository context file got code executed in Gemini CLI — disclosed in July 2025, fixed in 0.1.14. Invisible markdown in a pull request made Copilot Chat read private repositories and send them out through roughly a hundred image requests (CVE-2025-59145, CVSS 9.6). A pull request against a VS Code extension shipped a prompt telling the assistant to wipe the developer's filesystem (Amazon Q 1.84.0, CVE-2025-8217). And s1ngularity went the other direction: it invoked the AI CLIs already installed on victims' machines with the flags that skip confirmation, so the agents themselves picked what to steal.
Four failure modes, four different controls
Sandboxing a local agent isn't one setting. Each failure below needs its own mechanism, and the layers aren't interchangeable — a control that answers one often answers none of the others.
| Failure mode | How it shows up | The control that answers it |
|---|---|---|
| Writes outside the project | The agent edits ~/.zshrc, a git hook, a shared config, or a sibling repo it was never pointed at | Path allowlist with a deny-default stance |
| Persistence | A process, scheduler entry or launcher that comes back after a reboot; git primitives like core.fsmonitor, core.hooksPath or credential.helper within reach | Deny writes to start-up, plugin, extension, skill and config paths |
| Data leaving the machine | One HTTP request, one markdown image, one DNS lookup carrying a readable secret | Egress control, and credentials kept outside the boundary |
| Self-modification | The agent writes the settings file that guards its own tool calls | Profile read-only to the agent, and denies no grant can open |
Row four is the one people skip, and it's documented rather than hypothetical. In CVE-2025-53773 an injected instruction got an agent to write an auto-approval flag into the editor settings that decide whether its tool calls need confirmation. Later research found the same shape elsewhere: repository-controlled config files running shell commands at start-up, before any trust decision had been made, and blocked commands being retried with the escaping flag because the harness's own instructions said to retry immediately. If the agent can write the file that constrains it, that file isn't a constraint.
The profile defines the boundary, the launcher supplies the ergonomics
On macOS the primitive you get for bounding an arbitrary process is sandbox-exec with an SBPL profile. The shape that works is three layers, and only the first one enforces anything:
- The profile: deny default, then an explicit allow list — runtime paths, one workspace directory writable, harness state, temporary files.
- The launcher: resolves the parameters (workspace, state directory, escalation slots) and execs the agent under the profile, so nobody types the sandbox command by hand.
- The service wrapper: the same profile for a long-running daemon — a web UI or session server — because an agent started by a service isn't magically more trustworthy than one you started yourself.
Parameterisation is what keeps the thing maintainable. Read and write escalation slots get filled at launch and are empty by default; an empty slot points at a path that doesn't exist, so the shipped profile grants nothing extra. The profile is read once, at exec, and children inherit it — no prompt at runtime, no way to widen it from inside a running session. Escalation becomes an out-of-band request: the agent writes what it needs into the workspace, a human decides, and the change applies on the next start. That's a deliberate annoyance. A boundary the bounded process can renegotiate isn't a boundary.
The rule that matches last wins
You build exceptions by putting a targeted rule after a broader one, because the rule that matches last wins. A deny written after a permit closes the exception the permit opened. One nuance before you rely on it: a specific operation deny isn't undone by a later wildcard allow. And this is where the folklore is wrong — plenty of write-ups state flatly that deny always wins, which is a different claim that leads to a different rule order. Test it on the OS you ship, or you're building on folklore.
- Denying the filesystem root denies the root inode, and the dynamic linker aborts before your program starts. Allow the root literal, deny beneath it.
- A working directory inside a denied region breaks getcwd, and it surfaces as a missing command rather than a permission error.
- Paths are matched after symlink resolution, so /tmp is really /private/tmp. That closes an obvious escape and silently moves the path you thought you granted.
- Traversal needs metadata reads. Without them, resolving an intermediate directory fails, which looks like a broken binary rather than a policy mistake.
- Some allowances are pure compatibility: without read access to the interpreter's runtime, the agent doesn't start at all. Label them as compatibility inside the profile, or a later tidy-up will delete the part that made the boundary usable.
What the profile won't do for you
The limits are worth saying out loud. A profile that allows outbound network won't stop exfiltration: a readable credential plus one request is enough, and the harness's own model keys are readable by design, because the agent has to authenticate with them. Mach lookups usually stay broad, so system services stay reachable. Temp storage stays shared with the rest of the session. There's no CPU, memory or process bound, so a runaway loop is a denial of service rather than an escape. Metadata reads still map the disk even when content is denied. And it's a layer, not a platform guarantee — the sandbox-exec man page has carried a DEPRECATED notice since 2017, there's no supported replacement for bounding arbitrary commands, and the tool still ships and is still used by shipping software, Chromium included. What actually broke is nesting: wrapping a process that's already inside a sandbox fails with sandbox_apply: Operation not permitted. That's a lesson about placement, not mortality.
Enforcement can't live inside the agent
Two documented cases beat any argument. A session transcript shows an agent retrying commands the sandbox had blocked, with the escaping flag on, reasoning that its own instructions told it to retry blocked commands immediately. Separately, a major CLI had a stretch where its sandbox wrapper failed inside an already sandboxed context, and the working configuration was to run with the sandbox disabled.
Neither case is an indictment of the tools. Both are what a young boundary looks like. But they settle two design questions. Enforcement can't live in the agent's instructions, because the agent is also the thing you're containing. And verification has to be repeatable, because this failure mode doesn't raise an alarm — it just quietly stops applying. That's the argument for putting the checks in a script you re-run after every OS update, harness update and profile edit.
Sources
- OWASP Top 10 for LLM Applications (2025) — LLM01 prompt injection; OWASP Top 10 for Agentic Applications (2026) — ASI01 to ASI05, with execution sandboxes and egress controls named among the mitigations.
- Tracebit: Gemini CLI code execution via a repository context file (fixed in 0.1.14); Legit Security: CamoLeak, CVE-2025-59145; Embrace The Red: CVE-2025-53773, the agent disabling its own approvals; Nx postmortem: s1ngularity.
- sandbox-exec(1); Apple Sandbox Guide for filter and symlink semantics; Chromium macOS sandbox for a deny-default profile in shipping software; Anthropic sandbox-runtime for the rule-ordering notes behind that section.
- Claude Code sandboxing and the Codex CLI nested-sandbox report for the two cases in the enforcement section.
Frequently asked questions
Can I just rely on the agent's permission prompts?
Prompts are an interface to policy, not a boundary. They're worth having, and they fail in two specific ways: the approval flag can be flipped by anything that can write the config, and a prompt you see often enough trains you to click through it. Enforcement has to sit outside the process it constrains.
Why bound the launcher and not just the profile?
The profile is parameterised, so the workspace, the state directory and the escalation slots arrive as arguments at launch. Resolving them in one place is also where you refuse absurd values like the filesystem root or the whole home directory — which would quietly turn your allow list into a full-disk grant.
Is a deprecated mechanism worth building on?
Worth layering on, yes. The deprecation notice is old, the tool ships, it's used in production, and it's still the only convenient way to bound an arbitrary process on macOS. What the notice justifies is caution: verify the boundary after OS updates, keep the profile in version control, and don't let it become the only thing between untrusted code and your account.
Related
Seatbelt, container, VM
Which failures each isolation layer contains, five container defaults that delete the boundary, and which kind of VM is a real one.
Where the credential lives
Two shapes of agent isolation, the one that keeps keys out of the process, and the failure both shapes still miss.
Containing compromised agents
An agent will eventually be tricked, mistaken, or hijacked. Five architectural controls decide what it can still do when that happens.
Agent blast radius
A bad model decision becomes an incident through tool calls. Agent memory determines how long and how far the damage can spread.
AI security
How to secure GenAI, LLM and agentic AI systems: current OWASP threat classes, layered controls, agent authority, retrieval isolation and monitoring.
Agent sandbox hardening
Printable checklist for bounding a local coding agent: profile checks, container flags, credential handling and proof that the boundary works.