Sandboxing IDE Agent Extensions
IDE agent extensions are the most useful thing to happen to editors in a decade. Cline, Roo Code, Continue and the rest read your repo, propose edits, run the test suite, notice it failed, and fix it — inside the editor you already had open, against the code you already had checked out. The loop is tight because the agent is standing exactly where you are standing. That is also the entire problem.
Because where you are standing is a laptop with a shell profile, a populated environment, an SSH agent holding a key that opens production bastions, a cloud CLI session that was authenticated on Monday and expires on Friday, and a browser cookie jar one `curl` away. An extension does not get a special sandboxed subset of that. It gets your process context. This post is about a specific, boring engineering move that keeps the good part and deletes the bad part: leave the editing in the editor, and push every command the agent wants to execute into a remote microVM that belongs to that developer and that repo and nothing else.
What an editor extension actually inherits
Extensions run in the editor's extension host. When one spawns a terminal or a child process to run a command the agent proposed, that process is a normal child of your editor, on your machine, as you. Concretely, that means it starts life holding:
- Your workspace — the whole folder, not just the files currently open, plus whatever sibling directories a relative path can reach.
- Your shell profile — every alias, every function, every `export` in `.zshrc` that you added in 2019 and have not read since.
- Your environment variables — including the ones your app reads at runtime, which is a polite way of saying database URLs and API keys.
- Your cloud CLI sessions — `~/.aws/credentials`, an active `gcloud` account, a `kubectl` context pointed at something with a real name like `prod-eu`.
- Your SSH agent — forwarded keys, agent socket, and whatever those keys unlock. This one is rarely on people's list and it should be near the top.
- Your package manager caches and lifecycle scripts, which is the mechanism by which a single `npm install` becomes arbitrary code execution with all of the above still attached.
None of that is a criticism of any particular extension. It is a description of what a process on a developer laptop is. The extension authors did not choose it; the operating system did, decades ago, when it decided that a child process inherits its parent's world. The extension just introduced a new kind of decision-maker into that world — one that reads text written by strangers and turns it into commands.
The threat model is not a malicious extension
Almost every discussion of this lands on supply chain: what if the extension is malicious, or gets hijacked in an update? That is a real risk with real mitigations (pin versions, review publishers, watch the marketplace), and it is also not the risk that will actually bite you. The one that bites you is a completely well-meaning extension, doing exactly what it was designed to do, on instructions that entered through content.
An IDE agent reads things. It reads the README of the dependency you asked it to integrate. It reads a linked issue body to understand the bug. It reads the error output of a tool, the docstrings in `node_modules`, the CI log you pasted, and the web page it fetched because you said 'check the docs for this'. Every one of those is text authored by somebody who is not you, and every one of them lands in the same context window as your instructions, with no typographic marker saying which is which. If that text contains a paragraph shaped like a task — install this helper, run this setup script, export this variable to our endpoint so telemetry works — the model has no reliable way to know it was not you asking.
So the question is not 'do I trust this extension'. It is 'what is the blast radius when my agent obeys an instruction it read instead of an instruction I gave'. On a default laptop setup, the honest answer is: everything the developer can reach, including things the developer only reaches occasionally and would notice slowly.
The Approve button as a load-bearing security control
Every serious IDE agent ships an approve-each-command mode, and it is the correct default. Genuinely — the projects that built it thought about this properly, and if you have it on, you are in better shape than most. Check each project's own docs for the exact setting names and defaults, because they move between versions and I am not going to quote a config key that will be stale by the time you read this.
But approval is a control implemented in human attention, and human attention has a known failure mode. The first ten prompts get read. The next forty are `npm test`, `ls`, `cat package.json`, `npm test` again, and you develop a muscle memory that fires before your eyes finish the line. This is not a character flaw; it is the same reason nobody reads a EULA. Any control that costs a click and pays out nothing 99% of the time trains people to stop paying for it.
A security control that fires forty times an hour and is right forty times an hour is not teaching vigilance. It is teaching approval.
And then there is the other setting. Every one of these tools has some form of auto-approve, or an allow-list of commands that skip the prompt, because a human clicking Approve is also the thing that makes agents slow and irritating. Developers turn it on. They turn it on for good reasons, usually on a Friday when the agent is halfway through a refactor and the fortieth prompt is `pytest -q`. The moment it is on, the load-bearing control is gone and nothing about the environment changed to compensate. Your laptop has quietly become an unattended build server operated by whoever wrote the README the agent is currently reading.
The fix is not to ban auto-approve. The fix is to make auto-approve boring — to change the environment underneath so that an unattended shell is a shell into a disposable machine that holds nothing.
What stays local, what goes remote
This is the design decision that makes the whole thing palatable, because the naive version — 'run the entire IDE remotely' — is a much bigger change than anyone wants, and it throws away the latency and the ergonomics that made IDE agents good in the first place. You do not need it. The split runs cleanly along one line: reading and writing text is safe and should stay where it is fast; executing anything is not and should not.
- Editing files and rendering diffs — Runs in your editor: instant, uses your real buffers, your undo stack, your review UI, and touches nothing but text. Runs in a sandbox: pointless round trips and a worse diff experience for zero security gain.
- Reading the repo for context — Runs in your editor: already indexed, already open, no latency. Runs in a sandbox: slower, and the content is equally untrusted either way, since reading is not the dangerous verb.
- Terminal commands the agent proposes — Runs in your editor: inherits your env, your credentials, your SSH agent, your network position. Runs in a sandbox: a fresh guest kernel with none of those, where the worst case is a VM you were going to throw away.
- Installing dependencies — Runs in your editor: postinstall scripts execute as you, with your keys in the environment. Runs in a sandbox: postinstall scripts execute as nobody, in a machine with a scoped token and an egress allow-list.
- Running tests and builds — Runs in your editor: fights your laptop for CPU, and any test that calls a real service calls it with your real session. Runs in a sandbox: parallel, disposable, and pointed at fixtures or a scoped test database on purpose.
- Git operations — Runs in your editor: `git diff`, `git add -p`, staging and committing stay local, because they are how you review what the agent did. Runs in a sandbox: push access is where you draw the line — the sandbox gets a read-only clone token, and the human pushes.
- Secrets and cloud CLI sessions — Runs in your editor: everything you have ever authenticated, indefinitely, to anything the agent decides to invoke. Runs in a sandbox: exactly the one scoped credential the task needs, expiring with the sandbox.
- Network access — Runs in your editor: your full corporate network position, including internal services that trust your IP. Runs in a sandbox: default-deny, with a package-registry allow-list you wrote down and can audit.
Notice the shape of it. The developer keeps everything that felt good — the inline diffs, the instant edits, the review flow — and gives up only the part they were never really watching anyway. Nobody misses the terminal output being produced by their own kernel.
The pattern: workspace in, commands out
The mechanic is a small bridge between the extension and a per-developer sandbox. It does three things, and if you build it well it disappears:
- Materialise the workspace in the sandbox — clone the repo at the developer's current branch, then mirror local edits as they happen. A shallow clone plus file-level sync of the dirty set is enough; you are not building a distributed filesystem.
- Execute there — every command the agent proposes runs `cd /workspace/repo && <command>` inside the VM, never as a child of the editor.
- Stream the result back — stdout, stderr and exit code go to the extension's terminal view exactly as before, so the agent's observe step and the human's read-the-output step are unchanged.
Here is the sandbox side of that bridge. One microVM per developer per repo, holding a clone, taking file writes and commands.
from pandastack import Sandbox
class WorkspaceSandbox:
"""One microVM per developer, per repo.
The editor keeps the files, the diffs, and the undo history.
The sandbox keeps the shell.
"""
def __init__(self, repo_url: str, dev_id: str, branch: str = "main"):
# create() restores a baked Firecracker snapshot (~179ms p50), so the
# sandbox is ready before the developer's hand leaves the keyboard.
self.sbx = Sandbox.create(
template="base",
persistent=True,
ttl_seconds=3600,
metadata={"repo": repo_url, "dev": dev_id, "branch": branch},
)
self.sbx.exec(
f"git clone --depth 1 --branch {branch} {repo_url} /workspace/repo",
timeout_seconds=180,
)
def sync(self, rel_path: str, contents: str) -> None:
"""The extension edited a buffer locally. Mirror it before running anything."""
self.sbx.filesystem.write(f"/workspace/repo/{rel_path}", contents)
def run(self, command: str) -> dict:
"""The command the agent wanted to run in YOUR terminal. It runs in here."""
r = self.sbx.exec(f"cd /workspace/repo && {command}", timeout_seconds=300)
return {
"exit_code": r.exit_code,
"stdout": r.stdout[-8000:], # rendered to the human, read by the model
"stderr": r.stderr[-4000:],
}
def close(self) -> None:
self.sbx.kill()Two details do real work. `persistent=True` is what makes this feel like a dev box rather than a CI job — `node_modules` survives, the build cache survives, the second `npm test` is fast. And the truncation on stdout is not politeness: a `webpack` build log or a failing test suite with 400 assertions will otherwise flood the agent's context window, degrade its next decision, and cost you tokens for the privilege.
The editor side is a thin session registry, so that a window reload reattaches to the developer's existing sandbox instead of stranding one and creating another:
import { Sandbox } from "@pandastack/sdk";
// Key on developer + repo. Reloading the editor should reattach, not re-provision.
const sessions = new Map<string, Promise<Sandbox>>();
function workspaceFor(devId: string, repo: string): Promise<Sandbox> {
const key = devId + ":" + repo;
let sb = sessions.get(key);
if (!sb) {
sb = Sandbox.create({
template: "base",
persistent: true,
ttlSeconds: 3600,
metadata: { dev: devId, repo },
});
sessions.set(key, sb);
}
return sb;
}
// This is the function the extension calls INSTEAD of spawning a local shell.
export async function runAgentCommand(
devId: string,
repo: string,
command: string,
) {
const sb = await workspaceFor(devId, repo);
const r = await sb.exec("cd /workspace/repo && " + command, {
timeoutSeconds: 300,
});
return {
exitCode: r.exitCode,
stdout: r.stdout.slice(-8000),
stderr: r.stderr.slice(-4000),
};
}
// End of the working day, or the extension deactivating.
export async function teardown() {
for (const [key, sb] of sessions) {
await (await sb).kill();
sessions.delete(key);
}
}That is the whole architectural change. The agent's plan-execute-observe loop is untouched; only the machine underneath the execute step moved. From the developer's chair, the terminal panel still scrolls, the tests still go red, and the agent still notices and fixes them.
Credentials: a scoped token, not your ~/.aws
The most common way teams undo all of this in week two is by forwarding the developer's environment into the sandbox 'so the tests pass'. Do not. The entire value of the boundary is that the sandbox holds nothing worth stealing; the moment it holds a copy of `~/.aws/credentials`, you have built a remote laptop and paid for the network hop.
Forward nothing by default, and inject specific, scoped, short-lived credentials for the specific things a build genuinely needs. A read-only clone token for private dependencies. A test-database URL that points at a throwaway database, not a replica of production. Nothing that grants write access to anything a human would have to un-write. A per-developer policy file makes this reviewable, which matters more than it sounds — the point is that someone can read this in a pull request and see exactly what an agent can reach:
{
"workspace": {
"template": "base",
"ttl_seconds": 3600,
"sync": {
"include": ["src/**", "tests/**", "package.json", "pyproject.toml"],
"exclude": [".env*", "*.pem", "*.key", ".git/config", ".npmrc"]
}
},
"execution": {
"local": ["git status", "git diff", "git log", "git add", "git commit"],
"remote": ["*"],
"timeout_seconds": 300
},
"credentials": {
"forward_env": [],
"inject": [
{ "name": "GIT_CLONE_TOKEN", "scope": "read:repo", "ttl_seconds": 3600 },
{ "name": "DATABASE_URL", "value_from": "ephemeral-test-db" }
]
},
"egress": {
"default": "deny",
"allow": [
"registry.npmjs.org",
"pypi.org",
"files.pythonhosted.org",
"proxy.golang.org",
"github.com"
]
}
}Egress is the other half of the boundary
Isolation that only covers the filesystem is half a boundary. An agent that can reach the network can fetch instructions, exfiltrate the repo it is holding, or — the underrated one — reach an internal service that trusts requests coming from inside the corporate network. On a laptop that is guaranteed; in a sandbox on your own infrastructure it is a decision you make.
- Default-deny outbound, then allow-list. Package registries, your git host, and whatever API the app under test genuinely calls. Everything else fails loudly, which is also how you discover what your build actually depends on.
- Keep the sandbox off the network segment that reaches internal services. A per-sandbox network namespace with its own subnet — PandaStack pre-allocates 16,384 /30 subnets per agent host — means each VM's routing is a per-sandbox decision rather than an inherited one.
- Log egress denials and read them. A build that suddenly wants to reach an address nobody recognises is the highest-signal alert you will get out of this system, and it costs nothing.
- Do not allow-list by 'any HTTPS'. Exfiltration is a POST to a domain with a valid certificate, which is to say it looks exactly like every other request your build makes.
Filesystem and network isolation is covered in more depth in /blog/ai-agent-isolation-filesystem-network, and the egress-specific mechanics in /blog/controlling-network-egress-untrusted-code.
The latency budget, and why it works now
The reason nobody did this three years ago is that it used to feel bad. Remote execution meant waiting for a container scheduler, or keeping a warm pool of idle boxes and paying for them at 3am, and the first time a developer waited eleven seconds for `ls` they turned it off and never mentioned it again.
Two things changed. Snapshot-restore create made provisioning cheap enough to stop thinking about: on PandaStack every create restores a baked Firecracker snapshot rather than booting, which lands around 179ms p50 and 203ms p99, with no warm pool behind it. The first spawn of a template that has not been baked yet is a real cold boot, about 3s, and after that it is snapshot restores. That means a per-developer sandbox appears while they are still reading the agent's plan, and a per-task one is genuinely disposable.
The second is copy-on-write forking, which is what makes the 'try three approaches' pattern practical inside an editor. Warm one sandbox with the repo cloned and dependencies installed, then fork it per candidate change: same-host forks land in roughly 400–750ms, cross-host in 1.2–3.5s, and every branch inherits the install for free. The agent can run the test suite against three different fixes in parallel and show you the one that went green, which is a thing your laptop cannot do without becoming a jet engine. The mechanics are in /blog/how-ai-agent-sandboxes-work.
On cost, the arithmetic is unromantic: $0.054 per active vCPU-hour and $0.0162 per GiB-hour, billed on active use, means a sandbox that spends most of a session idle while a human reads a diff bills close to nothing for that idleness. The expensive part of an agent session is the model, not the machine. If someone argues against this on compute cost, they are optimising the smaller line item and buying a worse blast radius with the savings.
Rolling it out across a team
The unit that works is one sandbox per developer per repo. Not one per team — that is a shared machine, and shared machines accumulate other people's secrets, other people's half-finished branches, and eventually an incident where nobody can say whose agent did the thing. Not one per task either, at least not for the interactive editor loop; the warm dependency cache is most of what makes it feel fast, and rebuilding it per task hands the latency win straight back.
- Start with one repo and the developers who already have an IDE agent installed. This is a change to a workflow people like; forcing it fleet-wide on day one is how it gets routed around.
- Set a TTL on every sandbox — an hour is a reasonable default for an interactive session — so a closed laptop reaps itself. Refresh it on activity, not on a timer, so nothing dies mid-refactor.
- Tear down on editor deactivate, and let the TTL be the backstop for the crashes where your cleanup never runs. Every team that skips this discovers orphaned sandboxes via the invoice.
- Bake the repo's dependencies into a template once the shape stabilises. A sandbox that restores with `node_modules` already present removes the only genuinely slow step in the loop.
- Turn auto-approve back on, deliberately, and say so. This is the payoff: with execution behind the boundary, the setting that was reckless on a laptop is a productivity feature in a sandbox. If the rollout does not end with people being allowed to go faster, it will not stick.
- Keep the human on the git push. Review the diff locally, commit locally, push from a machine with a person attached to it. The agent's job ends at a green test run.
The bottom line
IDE agent extensions are good, and the reason they are good — the agent is standing exactly where you are standing — is also why a developer laptop is the wrong place for their shell. The threat is not a malicious extension; it is a well-behaved one executing a sentence that arrived through a README, a transitive dependency, or an issue body, while holding your cloud sessions and your SSH agent. Approve-each-command is the right default and a control made of human attention, which means it degrades under repetition and is one setting away from being off entirely. The move is to change the environment instead: keep editing, diffing and git review local where they are fast and harmless, push command execution into a per-developer microVM with a scoped token and a default-deny egress policy, and stream the output back so nothing about the loop feels different. Snapshot-restore creates around 179ms p50 and copy-on-write forks in the hundreds of milliseconds are what make that practical rather than theoretical. Do it once, and the worst thing an over-obedient agent can do at 3am is destroy a machine you were going to throw away anyway.
Frequently asked questions
Is approve-each-command enough to make an IDE agent safe?
It is the right default and materially better than nothing, but it is a control implemented in human attention. After the fortieth prompt for `npm test` in a session, approval becomes muscle memory rather than review — that is not a discipline failure, it is how repeated low-yield prompts work on people. And every one of these tools offers some form of auto-approve or command allow-list, which developers enable for entirely rational reasons, at which point the control is gone and nothing about the environment compensated. Keep approval on, but treat it as a usability affordance rather than your isolation boundary, and put the real boundary in the environment: execute commands somewhere that does not hold your credentials. Check your specific extension's current docs for the exact setting names, since they change between versions.
What can an IDE agent extension actually access on my machine?
Whatever your editor process can, because commands it runs are ordinary child processes started as you. In practice that means the full workspace folder and anything a relative path reaches, your shell profile with all its aliases and exports, your environment variables including the ones holding database URLs and API keys, any authenticated cloud CLI sessions (`~/.aws`, an active gcloud account, a kubectl context), your SSH agent and the keys it holds, and your position on the corporate network — including internal services that trust requests by source address. Package lifecycle scripts inherit all of it too, which is how a single dependency install becomes arbitrary code execution with your full context attached. That inheritance is not something the extension chose; it is what a child process is on a POSIX system.
Should the whole IDE run remotely, or just the commands?
Just the commands, in almost every case. Full remote development is a much larger change, and it gives up the latency and ergonomics that made IDE agents worth using — instant diffs against your real buffers, your undo stack, your review UI. The clean split is that reading and writing text is not the dangerous verb and should stay local, while executing anything is and should not. So editing, diff rendering, repo indexing, and local git review stay in the editor; terminal commands, dependency installs, test runs and builds go to a remote sandbox. The developer keeps everything that felt good and gives up only the part they were not really watching.
How do I handle credentials the build genuinely needs?
Inject specific scoped credentials, never forward the developer's environment. Forwarding `~/.aws` or the whole env into the sandbox recreates the laptop remotely and pays a network hop for the privilege — the value of the boundary is that the sandbox holds nothing worth stealing. Give it a read-only clone token for private dependencies, a URL for a throwaway test database rather than a production replica, and nothing that grants write access to anything a human would have to un-write. Tie credential lifetime to sandbox lifetime so a leaked value expires on its own. And exclude `.env`, `*.pem`, `*.key` and `.npmrc` from workspace sync explicitly, because those are the files that most often smuggle real secrets back into a sandbox that was otherwise clean.
Will remote execution make the agent loop feel slow?
Not if provisioning is cheap and the sandbox is warm. The historical problem was waiting on a container scheduler or paying for an idle warm pool; snapshot-restore changes that — on PandaStack every create restores a baked Firecracker snapshot at roughly 179ms p50 and 203ms p99, with a genuine cold boot (around 3s) only on the first spawn of a not-yet-baked template. Run one persistent sandbox per developer per repo so the dependency cache and build artifacts survive across commands, which removes the only genuinely slow step. Copy-on-write forking (roughly 400–750ms same-host, 1.2–3.5s cross-host) then lets the agent test several candidate fixes in parallel — something a laptop cannot do comfortably, so for multi-attempt work the remote version is faster, not slower.
Keep reading
- Sandboxes for AI agents — a microVM per developer, restored in ~179ms
- Build a sandboxed AI coding agent
- Filesystem and network isolation for agents
- Controlling egress for untrusted code
- A disposable dev environment per branch
49ms p50 cold start. Fork, snapshot, and scale to zero.