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.

    What goes wrong, and the control that answers it
    Failure modeHow it shows upThe control that answers it
    Writes outside the projectThe agent edits ~/.zshrc, a git hook, a shared config, or a sibling repo it was never pointed atPath allowlist with a deny-default stance
    PersistenceA process, scheduler entry or launcher that comes back after a reboot; git primitives like core.fsmonitor, core.hooksPath or credential.helper within reachDeny writes to start-up, plugin, extension, skill and config paths
    Data leaving the machineOne HTTP request, one markdown image, one DNS lookup carrying a readable secretEgress control, and credentials kept outside the boundary
    Self-modificationThe agent writes the settings file that guards its own tool callsProfile 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

    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.