all posts

How to sandbox code from Cline and Continue

Ajay Kumar··8 min read

The approval prompts work. That is the honest starting point, and it is also the problem. Cline and Continue both stop and ask before they run a terminal command, and for the first day you read every one of them. By the second day you have approved the same test command eleven times, and you go looking for whatever auto-approve or allow-list mode the tool offers, because confirming a command you already trust eleven times is not vigilance. It is a tax you eventually stop paying.

Now a model has an unattended shell on the machine that holds your SSH keys, your cloud credentials, your .env files, and every other repo you have checked out. Nothing about this requires anyone to be malicious. It is a confidently wrong rm with a path variable that came back empty. It is a dependency install whose postinstall script does more than install. It is a sentence in a README the agent read on your behalf, phrased helpfully, that the agent then acts on.

I build PandaStack, so that is the sandbox in the code below — the shape works with any provider. Most of this post is about the decision rather than the API, because the decision is where people go wrong. They either do nothing, or they try to move their entire development life into a container and quietly give up two weeks later.

What the extension actually reaches

Both tools are open-source AI coding assistants that live in your editor — Cline in VS Code, Continue in VS Code and JetBrains. Both can read and write files, both can run terminal commands, and both put an approval step in front of those actions. Beyond that level of description, check the current docs. Setting names and defaults in this category churn fast enough that anything specific I write here has a short shelf life.

The part worth internalising is the difference between file access and command access. File operations are scoped to the workspace, roughly, and that scoping is a usability feature. It keeps the agent focused on the project you opened. It is not a security boundary, and it barely matters either way, because the terminal is not scoped at all.

A shell the agent spawns is a shell. It inherits your environment: PATH, your cloud profile or the credential file behind it, a GitHub token, a kubectl context, an ssh-agent socket with a key already loaded, package manager config with registry tokens in it. Changing directory to another repo and force-pushing is a legal command. So is piping a downloaded script into sh. The workspace folder in the sidebar describes what the agent is thinking about. It does not describe what the agent can touch.

None of that is a criticism of either project. Any process running on a developer laptop has this reach. The difference is that this particular process takes instructions from text it read a minute ago, and some of that text came from the internet.

Three postures, ranked honestly

There are three real options. I am going to rank them, and the ranking is not the one that sells the most sandboxes.

1. Do nothing, keep approvals on

If you are working alone, on a repo you could delete and re-clone in a minute, on a machine that holds no production credentials, this is fine. Genuinely fine. Read the prompts, leave auto-approve off, and get on with your work. Every other option on this list has a real cost, and paying it for a risk you do not have is how people end up with tooling they resent and route around.

The moment it stops being fine is specific and easy to notice. Either you enable auto-approve, or the machine acquires a credential that can spend money or reach production. One of those two things happens to most people within a month. Until it does, do not build infrastructure.

2. Move the whole environment remote

The strongest posture. Repo, toolchain, dev server, terminal — all of it lives on a remote machine and your editor connects to it. VS Code remote development and the JetBrains equivalent make this a supported path rather than a hack, and the agent extension runs against that remote workspace, so everything it can reach is already inside a box you chose the contents of.

This is what I would pick for a team handling regulated or customer data, and it comes with a benefit nothing else gives you: onboarding a new laptop becomes a login rather than a day. The friction is equally real. Your editor behaves differently in a hundred small ways. Anything hardware-local needs a story, whether that is a device simulator, a USB dongle, a GPU, or a container daemon you were talking to over a local socket. And somebody has to own those remote environments, which is a job rather than a config flag.

If you already do remote development, you are done. Point the agent at the remote workspace and stop reading here. The rest of this is for people who are not going to make that migration this quarter, which is most people.

3. Edit locally, execute remotely

The pragmatic middle, and the one I actually run. Files stay on your disk, so everything that makes an IDE agent pleasant stays intact: inline diffs, instant edits, the editor you have configured over several years. One thing changes. When the agent decides to run a command, the command goes to a microVM holding a copy of the repo, and what comes back is stdout, stderr and an exit code.

The agent's loop does not notice. Plan, edit, run, read the output, adjust — the shape is identical, except the running now happens somewhere that has never seen your keys. And the auto-approve setting you were going to enable anyway becomes a much less interesting decision, because the worst realistic outcome is a broken sandbox you throw away and recreate.

The bridge

Both tools can call out to external tools, and both speak MCP, so there is more than one place to wire this in. Rather than pin down a config schema that will have moved by the time you read this, here is the piece that matters: the thing sitting on the other end of whatever wiring your editor uses. Give it a command, it returns output.

# pip install pandastack
import os, io, pathlib, subprocess, tarfile, time
from pandastack import Sandbox

WORKSPACE = pathlib.Path(os.environ["AGENT_WORKSPACE"]).resolve()
REMOTE = "/workspace/repo"

# The real specification of this whole design is the deny list.
DENY_NAMES = {".env", ".env.local", ".npmrc", ".netrc", "id_rsa", "id_ed25519"}
DENY_SUFFIX = (".pem", ".key", ".p12")

def _repo_files():
    """Tracked + untracked-but-not-ignored, minus anything secret-shaped."""
    out = subprocess.run(
        ["git", "ls-files", "-co", "--exclude-standard"],
        cwd=WORKSPACE, capture_output=True, text=True, check=True,
    ).stdout
    for rel in out.splitlines():
        name = os.path.basename(rel)
        if name in DENY_NAMES or rel.endswith(DENY_SUFFIX):
            continue
        yield rel

_sbx, _synced_at = None, 0.0

def _session():
    global _sbx
    if _sbx is None:
        _sbx = Sandbox.create(
            template="base",
            persistent=True,
            metadata={"repo": WORKSPACE.name, "dev": os.environ.get("USER", "?")},
        )
        _sbx.exec("mkdir -p " + REMOTE)
    return _sbx

def run_command(command: str, cwd: str = REMOTE, timeout_seconds: int = 300) -> dict:
    sbx = _session()
    _sync(sbx)
    r = sbx.exec("cd " + cwd + " && " + command, timeout_seconds=timeout_seconds)
    return {
        "stdout": r.stdout[-20000:],
        "stderr": r.stderr[-20000:],
        "exit_code": r.exit_code,
    }

Two details do the work. Creating the sandbox as persistent means it survives between commands, so the second test run does not reinstall dependencies — that is the difference between a dev loop and a CI job, and getting it wrong is why remote execution has a reputation for feeling slow. The other is that the sync is incremental. Full upload when the session first attaches, and after that only the files that changed since the last command.

def _sync(sbx) -> None:
    """Tar up whatever changed since the last command and unpack it remotely."""
    global _synced_at
    started = time.time()
    changed = [
        rel for rel in _repo_files()
        if (WORKSPACE / rel).is_file()
        and (WORKSPACE / rel).stat().st_mtime > _synced_at
    ]
    if not changed:
        return

    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tar:
        for rel in changed:
            tar.add(WORKSPACE / rel, arcname=rel)

    sbx.filesystem.write("/tmp/delta.tar.gz", buf.getvalue())
    sbx.exec("tar xzf /tmp/delta.tar.gz -C " + REMOTE)
    _synced_at = started

Deletions are the obvious gap in that helper, and how much you care depends on the repo. The cheap fix is to also send the paths git reports as deleted and remove them on the far side. The lazy fix is to recreate the sandbox when the agent does something structural, which on a snapshot-restore platform costs about as much as a slow HTTP request rather than a container build.

Do not sync the whole directory tree with a recursive copy. The first person to try this always uploads node_modules, a virtualenv and a .git directory, concludes that remote execution is unusably slow, and goes back to running everything locally. Ask git what belongs to the project, then subtract the secret-shaped files.

Credentials, and why the network namespace matters

The most common way to undo all of this happens in week two, when someone makes the sandbox convenient by copying their local environment into it. Do not do that. The deny list above is not decoration — it is the actual specification. Forward nothing by default.

If the test suite genuinely needs to reach a database or an API, mint a credential for that sandbox rather than reusing yours. Be concrete about what scoping buys you: a long-lived cloud key in the agent's shell means the blast radius of one bad command is your whole account, while a token good for one bucket and two hours means the blast radius is that bucket for two hours. Same failure, different Monday.

sbx = Sandbox.create(template="base", persistent=True)

# Injected, not forwarded: minted for this sandbox, short-lived, revocable.
sbx.filesystem.write(
    "/workspace/repo/.env",
    "DATABASE_URL=" + mint_scoped_db_url(ttl_minutes=60) + "\n"
    "API_TOKEN=" + mint_read_only_token(ttl_minutes=60) + "\n",
)

The other half of the boundary is egress, and here a sandbox does something your laptop cannot. Each PandaStack sandbox gets its own network namespace, so the rules about what it may reach are enforced outside the guest, on interfaces no process inside the guest can reconfigure. Allow your package registry and your internal API, deny everything else, and code running inside has no argument to make about it.

Try the equivalent on a laptop and you are writing firewall rules for a machine that also runs your browser, your chat client, your VPN and your actual work. Nobody maintains those rules. They break something on a Tuesday and get deleted on a Wednesday. Per-sandbox rules survive precisely because they govern one narrow thing that does nothing else.

What this costs you, plainly

Posture 3 is a trade rather than a free upgrade, and the bill arrives in two places.

Latency first. Every command now carries a sync and a round trip. Against anything worth running — a test suite, a build, a lint pass, an install — that overhead disappears into the noise. Against a 200 millisecond unit test that the agent wants to run forty times while it iterates on one function, you will feel every one of them. The honest answer there is to keep that particular loop local and send the sandbox everything that installs, downloads, or writes outside the repo. A rule that applies to some commands is worth more than a rule so absolute that you turn it off.

Local state is the sharper edge, and it is where this posture actually breaks. The dev server you had running on port 3000 is not in the sandbox. The container daemon you were talking to is not there. Native modules built against your architecture may not load. The database with the useful seed data in it is somewhere else. Your debugger will not attach to a process inside a microVM without deliberate work.

Some of that is solvable by moving more things in — run the dev server in the sandbox and expose the port, run Postgres in there too, and the picture gets better. Some of it is not worth solving, and what you end up with is a split: the agent's commands go remote, and a handful of your own stay local. Anyone who tells you the switch is transparent has not run it for a week.

Where I would draw the line

  1. Solo, on a repo you could re-clone, on a machine with nothing dangerous in its environment: keep approvals on and skip all of this.
  2. The first time you enable auto-approve: route execution out. That setting is the trigger, because it converts a reviewed action into an unattended one.
  3. A machine holding credentials that can spend money or reach production: move the whole environment remote rather than only the commands.
  4. Across a team: one sandbox per developer per repo. Never a shared one — a shared box is a shared blast radius, and the audit trail turns to mush the moment two people are inside it.

Cline and Continue are good tools, and what makes them good is exactly what makes them risky. The agent stands where you stand, with everything you have, which is why it can be so useful so quickly. You do not fix that by forcing yourself to click more buttons, because you will stop clicking them. You fix it by making the place it stands smaller.

Frequently asked questions

Is auto-approve in an IDE coding agent safe if I only allow specific commands?

It helps, and it is better than approving everything, but an allow-list is weaker than it looks. Most allowed commands are package managers and test runners, and those execute arbitrary code from the project — a test file, a build script, a dependency's install hook. Allowing the command allows everything the command can run. Treat the allow-list as a convenience that reduces prompt fatigue, not as the control that contains a mistake. The control is the environment the allowed command runs in.

Do I have to move my whole development environment into a sandbox?

No, and for most people that is the wrong first step because the friction is high enough that the change gets reverted. Moving only command execution keeps files on your disk, so inline diffs and editor behaviour are unchanged, and it removes the specific risk you care about: unattended model-chosen commands running next to your keys. Move the whole environment when you handle regulated or customer data, or when the laptop holds credentials that reach production. Otherwise start with execution and see how it feels.

How does the sandbox get my code without slowing the loop to a crawl?

Attach once, sync deltas after that. Ask git which files belong to the project, subtract anything secret-shaped, upload that set on first attach, and afterwards send only files whose modification time is newer than the last sync. Keep the sandbox persistent so dependencies survive between commands, which is what stops every run behaving like a cold CI job. On a snapshot-restore platform the create itself is cheap enough to ignore; the cost you have to manage is transfer, not provisioning.

What credentials should the sandbox have?

As few as possible, and none of yours. Forwarding your environment for convenience undoes the entire point, so exclude .env files, key material and package manager config from the sync, then inject purpose-built credentials for whatever the build actually needs. Scoped to specific resources, short-lived, revocable, and different from the ones on your laptop. The test is simple: if a command inside the sandbox went badly wrong, what could it reach? That answer should be a list you can write down.

What breaks when I move execution off my machine?

Anything that depended on local state. A dev server already running on a local port, a container daemon reached over a local socket, native modules compiled for your architecture, a database with your seed data in it, and debugger attach. Some of that you fix by moving those services into the sandbox as well and exposing the ports. Some of it you accept, and you end up running a handful of commands locally on purpose. Expect a split rather than a clean cutover, and decide the split deliberately.

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.