all posts

How to give Goose a code execution sandbox

Ajay Kumar··9 min read

Goose, Block's open-source agent, makes a different bet from most of the field. It is not a hosted agent you send a repository to. It runs on your machine, with your shell, your files, your CLIs and your already-authenticated sessions, and it grows new capabilities through MCP-style extensions that you install the way you install anything else. That is why people like it. It skips the entire class of problem where an agent is technically brilliant and practically useless because it cannot see your environment.

It is also worth saying out loud what 'on-machine' means when you write it in a threat model rather than in marketing copy. It means the agent is inside the blast radius. It has your shell, your filesystem, and the union of every credential every enabled extension holds. None of that is a flaw in Goose — it is the deal, stated clearly in the design. The question is whether you have decided which parts of that deal you actually want.

What on-machine buys you, and what it costs

The value is context. A remote agent asked to fix a failing test has to be told what the project is, how it builds, which of six Python versions is the real one, and where the fixture data lives. Goose can just look. It runs the same command you would run, gets the same output you would get, and does not need you to reconstruct your environment inside a container definition first. For day-to-day developer work that difference is enormous, and any advice that amounts to 'put the whole thing in a box' throws it away.

The cost is that the default privilege set is 'you'. The developer extension gives it a shell. Other extensions bring their own reach: a cloud CLI with your session, a ticket tracker with a write token, a database client pointed at something real. Each one is fine in isolation. The thing to reason about is the union, because the agent can compose them, and the worst plausible sequence of tool calls is your actual exposure — not the worst single call.

The dangerous combination is capability plus untrusted input. An agent with a shell is only as safe as the text steering it. The moment Goose reads a web page, an issue description, a dependency's README or a log file from a shared system, the instructions it follows are partly written by someone who is not you. That is the case where 'it runs as me' stops being convenient and starts being the whole problem.

Which Goose actions actually deserve a boundary

Not all of them, and pretending otherwise is how these posts get ignored. Reading your files, grepping your repo, explaining a stack trace, drafting a patch — those need local context and produce nothing you cannot review before it lands. Sandboxing them makes Goose worse and buys you very little.

Three categories are different:

  • Anything that executes code the model wrote. Running a generated script, a one-off data transformation, a scratch benchmark — the code is new, unreviewed, and the only thing between it and your home directory is a prompt asking it to be careful.
  • Anything that installs. pip, npm, cargo, brew, curl piped into a shell. Installs run arbitrary maintainer code by design, they mutate your global toolchain, and they are the single most common way an agent session leaves permanent damage behind.
  • Anything downstream of fetched content. Web pages, scraped docs, third-party issue text. If the agent read it and then decided to run something, the decision was influenced by a stranger.

That is the split worth engineering. Keep Goose local for reading and reasoning about your code. Push execution — especially execution of anything it just wrote or just downloaded — into a machine that does not have your keys in it.

A sandbox extension Goose can call

Goose's extension model is the lever here. Extensions are MCP servers, so an execution sandbox is just another extension — one that happens to run commands in a Firecracker microVM instead of in your shell. I build PandaStack, so that is the backend in this example; the server shape works against any sandbox provider with an exec API.

# pip install pandastack "mcp[cli]"
import os
from mcp.server.fastmcp import FastMCP
from pandastack import Sandbox

mcp = FastMCP("sandbox")

_sb: Sandbox | None = None
_ctx = None


def _sandbox() -> Sandbox:
    """One microVM, created lazily on the first tool call of a session."""
    global _sb
    if _sb is None:
        _sb = Sandbox.create(
            template="code-interpreter",
            ttl_seconds=3600,
            metadata={"agent": "goose", "session": os.environ.get("SESSION_ID", "adhoc")},
        )
        _sb.exec("mkdir -p /workspace", timeout_seconds=30)
    return _sb


@mcp.tool()
def sandbox_shell(cmd: str, timeout_seconds: int = 120) -> str:
    """Run a shell command in an isolated VM. Use this instead of the local
    shell for anything that installs packages, executes generated code, or
    runs something fetched from the internet."""
    r = _sandbox().exec(f"cd /workspace && {cmd}", timeout_seconds=timeout_seconds)
    body = "\n".join(p for p in (r.stdout, r.stderr) if p)
    return f"exit={r.exit_code}\n{body[:8000]}"


@mcp.tool()
def sandbox_python(code: str, timeout_seconds: int = 120) -> str:
    """Run Python in a persistent kernel inside the VM. Variables, imports and
    files survive between calls, so build up state across steps."""
    global _ctx
    if _ctx is None:
        _ctx = _sandbox().create_code_context(language="python")
    ex = _ctx.run_code(code, timeout_seconds=timeout_seconds)
    out = "\n".join(p for p in (ex.stdout, ex.stderr, ex.error or "") if p)
    return out[:8000] or "(no output)"


@mcp.tool()
def sandbox_put(path: str, content: str) -> str:
    """Write a file into the sandbox workspace before running against it."""
    _sandbox().filesystem.write(f"/workspace/{path}", content)
    return f"wrote /workspace/{path}"


if __name__ == "__main__":
    mcp.run()

Two details in there are doing more work than they look. The tool descriptions name the situations rather than describing the tool — models are conservative about calling something labelled 'a sandbox' and reliable about calling something labelled 'use this for anything that installs packages'. And the output is truncated before it goes back to the model, because an agent that cats a build log into your context window will do it again, and the bill is real.

Wiring it into a session

Goose loads extensions from its config file, and the practical move is to keep two profiles: one with the local developer extension for reading and reasoning, and one where execution goes to the sandbox instead. The stanza looks roughly like this — key names have moved between releases, so run the interactive configure command and check the result against your version's docs rather than copying this blind.

# ~/.config/goose/config.yaml
extensions:
  # Local reading and reasoning: keep this. It is the reason you use Goose.
  developer:
    enabled: true
    type: builtin
    name: developer

  # Execution goes somewhere that has never seen your credentials.
  sandbox:
    enabled: true
    type: stdio
    name: sandbox
    cmd: python
    args: ["/opt/goose-ext/sandbox_server.py"]
    timeout: 300
    envs:
      PANDASTACK_API_KEY: "pds_..."   # scoped key, sandbox API only
Verify the extension schema against the Goose docs for the version you are running — extension configuration is one of the faster-moving parts of the project, and a stanza that silently fails to load leaves you with an agent that quietly falls back to the local shell. Start a session and confirm the tool is actually listed before you trust the boundary.

Then say so in the prompt or in your project instructions. Extensions are offered, not enforced: if the local shell is still available, the model will sometimes use it because it is the shorter path. A single line — install anything, and run anything you just wrote, in the sandbox — moves behaviour more than any amount of tool-description tuning.

Keeping extension credentials out of the sandbox

This is the part people get backwards. Having moved execution into a VM, the instinct is to make the VM useful by forwarding the environment — the cloud CLI config, the registry token, the database URL — and at that point you have rebuilt the original blast radius, just further away, where you can see it less well.

The sandbox should be the least-privileged thing in the setup. Give it the one credential the task needs, scoped and short-lived, and nothing else. Keep the extensions that hold real authority — your ticket tracker, your cloud account, your production database client — attached to the local Goose process, where they operate through narrow, purpose-built tool calls rather than through a shell that can compose them into something you did not anticipate.

  • No wildcard environment forwarding. Pass named variables, or none.
  • A read-only token beats a read-write one, and an expiring token beats both.
  • Egress off by default. Turn it on for the step that needs a package index, then turn it back off.
  • Never mount your home directory. Copy in the specific files the task needs; copy the results back out.

Per session or per task?

Per session is the natural fit for Goose, because a Goose session is already the unit the user thinks in. The example above creates one VM on first use and keeps it for the session, which means the agent can install a dependency in step three and still have it in step eleven — exactly the continuity that makes a local agent pleasant.

Per task is stricter, and worth it when the tasks are untrusted or unrelated. If Goose is chewing through a queue of issues, or evaluating code from a repository you have not read, a fresh VM per item means nothing one task leaves behind can affect the next. On PandaStack that costs about 179ms at p50 to create — every create restores a baked snapshot rather than booting, so a VM per task is not a latency decision, it is a policy decision.

There is a middle setting that suits Goose particularly well: one long-lived session sandbox for ordinary work, plus a forked copy for anything speculative. A same-host fork lands in 400 to 750ms and gives you a copy-on-write clone of the session's current state, so the agent can try the risky refactor in a branch of the machine and you can throw the whole branch away if it goes badly.

from pandastack import Sandbox

# Seed the session sandbox with just the code, not your whole disk.
sb = Sandbox.create(template="code-interpreter", ttl_seconds=3600)
sb.filesystem.upload("./service.tar.gz", "/workspace/service.tar.gz")
sb.exec("cd /workspace && tar xzf service.tar.gz && rm service.tar.gz", check=True)

# Pre-install what the agent will need, once, with the network briefly open.
sb.exec("pip install -q -r /workspace/requirements.txt", timeout_seconds=300)

try:
    # ... Goose session runs, calling the sandbox extension ...
    diff = sb.exec("cd /workspace && git diff", timeout_seconds=60).stdout
finally:
    sb.kill()

Pre-installing matters more than it looks. An agent that has to install its own dependencies mid-run needs network access for the whole session, handles install failures badly, and produces runs that depend on what the package index served that afternoon. Install up front, close the network, and the run becomes reproducible as a side effect.

On-machine versus sandboxed, honestly

  • Shell commands — On-machine: run as you, with your dotfiles, your SSH agent and every already-authenticated CLI session. Sandboxed: run as a user in a VM that has never held a credential of yours.
  • Filesystem — On-machine: your whole disk is in scope, minus whatever you remembered to deny. Sandboxed: one workspace directory you deliberately copied in.
  • Package installs — On-machine: mutate your global toolchain, permanently, in ways nobody writes down. Sandboxed: mutate a VM you are going to delete anyway.
  • Network — On-machine: your VPN, your intranet, your localhost services, your cloud metadata endpoint. Sandboxed: whatever egress you explicitly allowed, and nothing else.
  • Context quality — On-machine: complete, which is the entire reason to use Goose. Sandboxed: only what you put in the workspace, so seeding it well is now your job.
  • Recovery from a bad step — On-machine: restore from backup and hope the backup is recent. Sandboxed: delete the VM.
  • Startup cost — On-machine: zero, the shell is already there. Sandboxed: a snapshot restore, around 179ms p50 on PandaStack, and about 3s the first time a template is cold.
  • Where the risk lives — On-machine: with you, permanently. Sandboxed: with a machine whose worst outcome is that you throw it away.

Read that list and the conclusion is not 'sandbox everything'. It is that the top half favours local and the bottom half favours isolated, and the split runs almost exactly along the line between reading and executing.

Teardown, and why the TTL is not the plan

Goose sessions end in every way a process can end: cleanly, with a Ctrl-C, with a laptop lid closing on a train. Only the first of those runs your cleanup code, which is why the TTL is set at creation rather than left to a shutdown hook. The platform reaps the VM regardless of what happened to your terminal.

That is the backstop, not the mechanism. Kill the sandbox explicitly when the session ends so you stop paying immediately and so the workspace — which by now contains everything the session touched — does not sit around for an hour waiting for a timer. Billing is per second at $0.054 per active vCPU-hour and $0.0162 per GiB-hour, so a sandbox idling while the model thinks costs very little; a fleet of forgotten ones still adds up, and more to the point, a forgotten sandbox is a forgotten copy of your code.

The short version

  1. Keep the developer extension local. Reading and reasoning about your code is what Goose is for, and isolating it removes the value.
  2. Move three things into a VM: executing generated code, installing anything, and acting on content fetched from the internet.
  3. Ship the boundary as an MCP extension with tool descriptions that name situations, not capabilities.
  4. Give the sandbox one scoped credential and no network unless the step needs it. Credentialed extensions stay on the local side.
  5. One sandbox per session by default, per task when the work is untrusted, a fork when the agent wants to try something reckless.
  6. TTL at creation as the backstop, explicit kill as the plan, and check that the extension actually loaded before you trust any of it.
The goal is not to take Goose off your machine. It is to make sure the only things running as you are the things you would have run yourself.

Frequently asked questions

Does sandboxing Goose defeat the point of an on-machine agent?

It does if you sandbox all of it. The value of Goose is that it can see your real environment without you rebuilding it inside a container definition, and moving the whole agent to a remote VM throws that away. The split that works is narrower: reading, searching and reasoning about your code stay local, while executing model-written code, installing packages, and acting on fetched web content go into a VM. That keeps the context that makes the agent good and removes the actions whose worst case is permanent.

How do I stop Goose from just using the local shell instead of the sandbox extension?

Two levers, and you need both. Write the sandbox tool descriptions so they name situations rather than capabilities — 'use this for anything that installs packages or runs code you just wrote' gets called far more reliably than 'a secure sandbox'. Then state the rule in your session or project instructions, because extensions are offered rather than enforced and the local shell is always the shorter path. If you need a hard guarantee rather than a strong preference, disable the local developer extension in that profile so there is no fallback to take.

Should the sandbox have access to my credentials and extensions?

No, and this is the mistake worth avoiding deliberately. Once execution is remote, the temptation is to forward your environment so the VM is useful again, at which point you have recreated the original exposure somewhere you supervise less closely. Give the sandbox one scoped, short-lived credential for the specific task and nothing more. Extensions that hold real authority — cloud accounts, ticket trackers, production database clients — belong on the local side, where they act through narrow tool calls instead of through a shell that can chain them together.

One sandbox per Goose session, or one per task?

Per session matches how people actually use Goose and preserves continuity: a package installed in step three is still there in step eleven. Switch to per task when the work is untrusted or the items are unrelated — a queue of issues, or code from a repository you have not read — so nothing one task leaves behind reaches the next. Creating a sandbox is a snapshot restore rather than a boot, around 179ms at p50 on our platform, so this is a policy choice rather than a performance one. Never share a sandbox between users.

Can the agent still work with my repository if execution is in a VM?

Yes, but seeding the workspace becomes your job rather than something the filesystem does for free. Copy in the specific project the task concerns, rather than mounting your home directory, then read results back out through the filesystem API when the run finishes. In practice this is one upload and one read, and it has a useful side effect: the agent's working set is now something you chose explicitly, which makes it much easier to answer the question of what a given session could possibly have touched.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.