DEV Community

Riley Xu
Riley Xu

Posted on

Migration Diary: Rebuild the Command Sandbox Before You Leave a Paid Coding Agent

The leftover that actually hurts during cutover is not a missing model brand; it is the sandbox the paid product never exposed. You can copy prompts, tools, and stop rules and still ship an agent that runs destructive shell on a free server. Freeze a command policy before you leave, then let every new run fail closed against that policy. This diary walks through a local policy file, a validator, and a cutover checklist you can run in one evening.

The leftover is the fence, not the chat

Paid coding agents wrap every shell call in a host you never configured, so the allowlist lives in their UI, not your repo. After you cancel, that wrapper disappears, and your loop talks to a raw terminal or an unmanaged worker process. The leftovers are not chat transcripts; they are implicit denials, working-directory jails, and silent network blocks. If you skip this inventory, a free model on a free server will look more capable only because it is less fenced.

You should treat the paid product as a black box that already answered four questions you must now write down. Write down which binaries may start, which flags are forbidden, which directories may be the working tree, and whether network is allowed. Those answers form a contract your next runtime has to enforce even after you change generators or hosts. Store the contract in git before you export a single chat, because chats do not block a dangerous binary.

Capture the paid behavior before you pull the plug

Work through the capture steps while the paid agent can still run a harmless command in your project. Do not wait until the invoice cycle ends and the vendor sandbox evaporates without leaving a dump. Keep a plain text file beside the repository and record denials in the same words the product used. You are not collecting slogans from the settings page; you are collecting the commands that already failed.

  1. Run one read-only command, one write command, one network command, and one process-spawn command.
  2. Record whether each call was allowed, rewritten, timed out, or blocked without a clear error.
  3. Note the working directory the tool actually used, not the directory you assumed from the prompt.
  4. Copy any UI labels that mention sandbox, restricted mode, or approved commands into that text file.

A denial is the most honest documentation the vendor will ever give you during this migration. Save the four outcomes even when they look boring, because boring denials are the policy. If a command succeeded in a surprising directory, treat that success as a finding rather than a convenience. Those four notes become the first draft of the YAML file in the next section.

Artifact: a fail-closed command policy

Put a policy file in the repository so the next runtime cannot invent permissions during a helpful-looking retry. The example below is a proposed contract, not a claim about any vendor's hidden defaults. Keep fail_closed true, because an empty allowlist must mean deny rather than mean trust the model. Commit this file with the capture notes so a reviewer can see why each binary was admitted.

# sandbox_policy.yaml — proposed local contract
version: 1
fail_closed: true
working_tree:
  allowed_roots:
    - "."
  deny_path_prefixes:
    - "/etc"
    - "/home"
    - "~"
    - ".."
network:
  allow: false
process:
  max_runtime_ms: 15000
  max_output_bytes: 65536
commands:
  allow:
    - argv_prefix: ["git", "status"]
    - argv_prefix: ["git", "diff"]
    - argv_prefix: ["python", "-m", "pytest"]
    - argv_prefix: ["ls"]
    - argv_prefix: ["rg"]
  deny_substrings:
    - "rm -rf"
    - "sudo"
    - "curl "
    - "wget "
    - "ssh "
    - "chmod 777"
Enter fullscreen mode Exit fullscreen mode

The YAML also carries timeout and output caps that your subprocess wrapper must enforce later. The checker in the next section only validates argv, which is the leftover most teams forget to write down. Do not treat prefix matching as a container, and do not add bash -c to the allowlist to make a demo pass. If a paid-agent command cannot be expressed as an argv prefix, that command is not ready to move.

Run the checker before any cutover

Pair the policy with a small checker that the agent loop must call before subprocess. Label this as a proposed local gate you should run on fixtures, not as a production security audit. Fail the process when a fixture disagrees, because a yellow log line will not stop a cutover on a Friday. Keep the fixtures in git beside the policy so model swaps cannot quietly widen the shell.

# check_sandbox.py — proposed local gate, unexecuted until you wire fixtures
from __future__ import annotations

import json
import shlex
import sys
from pathlib import Path

import yaml

DENY_DEFAULT = "command rejected by sandbox_policy.yaml"


def load_policy(path: Path) -> dict:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not data or not data.get("fail_closed"):
        raise ValueError("policy must set fail_closed: true")
    return data


def argv_list(command: str) -> list[str]:
    return shlex.split(command, posix=True)


def is_allowed(command: str, policy: dict) -> tuple[bool, str]:
    argv = argv_list(command)
    if not argv:
        return False, "empty command"
    joined = " ".join(argv)
    for blob in policy["commands"].get("deny_substrings", []):
        if blob in joined:
            return False, f"denied substring: {blob!r}"
    if policy.get("network", {}).get("allow") is False:
        if argv[0] in {"curl", "wget", "ssh", "scp", "nc"}:
            return False, "network binaries are blocked"
    for root in policy["working_tree"]["deny_path_prefixes"]:
        if any(str(part).startswith(root) for part in argv[1:]):
            return False, f"path prefix blocked: {root}"
    for rule in policy["commands"].get("allow", []):
        prefix = rule["argv_prefix"]
        if argv[: len(prefix)] == prefix:
            return True, "allowed prefix match"
    return False, DENY_DEFAULT


def main() -> int:
    policy = load_policy(Path("sandbox_policy.yaml"))
    fixtures = json.loads(Path("sandbox_fixtures.json").read_text(encoding="utf-8"))
    failures = []
    for row in fixtures:
        ok, reason = is_allowed(row["command"], policy)
        if ok != row["expect_allow"]:
            failures.append({**row, "reason": reason})
    if failures:
        print(json.dumps(failures, indent=2))
        return 1
    print("sandbox fixtures passed")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Add fixtures that encode yesterday's paid-agent denials, not the commands you wish had been allowed. Include at least one path-escape case, one network case, and one extra git subcommand that should still fail closed. If a fixture is annoying to write, that annoyance is the migration working as designed.

[
  {"command": "git status", "expect_allow": true},
  {"command": "git diff --stat", "expect_allow": true},
  {"command": "git push origin main", "expect_allow": false},
  {"command": "python -m pytest tests/test_sandbox.py", "expect_allow": true},
  {"command": "rm -rf /", "expect_allow": false},
  {"command": "curl https://example.com", "expect_allow": false},
  {"command": "ls ../secrets", "expect_allow": false}
]
Enter fullscreen mode Exit fullscreen mode

git diff --stat passes because the allow rule matches an argv prefix, not a full string. git push origin main fails because push was never admitted, which is usually what you wanted on a free server. ls ../secrets fails on the path prefix even though ls itself is allowed. Tune prefixes in review when a historical paid command must keep working, and do not tune them inside a prompt.

Cutover plan you can finish before the subscription lapses

Treat cutover as a change to enforcement, not a change to chat tone or model branding. Each step should produce a file or a command output you can paste into the pull request. If a step produces only a feeling, you have not finished it. Stop when the checker is green against the paid-agent leftovers, not when the new chat looks fluent.

  1. Freeze sandbox_policy.yaml and sandbox_fixtures.json in the same commit so reviewers see denials next to code.
  2. Route every tool-calling path through is_allowed and refuse to start a subprocess on a deny.
  3. Log the reason string; a silent block will look like a model failure and waste a retry budget.
  4. Replay yesterday's paid-agent commands against the fixtures until every historical allow still allows.
  5. Enforce max_runtime_ms in the subprocess wrapper, because argv checks do not kill hung processes.
  6. Only then point the loop at a free model runtime, keeping the same policy file unchanged.

Run the checker from the repository root before you start the agent loop on any host you actually operate.

python3 -m pip install pyyaml
python3 check_sandbox.py
Enter fullscreen mode Exit fullscreen mode

If the checker exits nonzero, do not start the agent loop on that revision. Fix the policy or the fixture first, then rerun until the output is a single passing line. A red checker is cheaper than a surprise network call on a shared server. Keep the command in CI so a later prompt change cannot reopen the fence.

Map leftovers to local replacements

Paid products hide policy in toggles, so you need a table that names each leftover and the file that now owns it. Fill the table from your capture notes rather than from vendor documentation, because the UI often overstates isolation. Keep the table in the same commit as the YAML file so a reviewer can see both the claim and the enforcement. If a row has no local replacement, you are not ready to cut over.

Paid leftover Local replacement Failure if skipped
Restricted-mode toggle fail_closed: true unknown commands execute
Working directory jail allowed_roots and deny prefixes writes leave the repo
Blocked outbound HTTP network.allow: false tools exfiltrate context
Approved command palette argv_prefix allowlist unexpected binaries start
Hidden tool timeout max_runtime_ms in the wrapper hung processes on a free server

Use the table as a punch list during the last week of the paid seat. Check a row only when a file or a test proves the replacement exists. Do not check a row because a model promised it would be careful. Careful is not an enforcement mechanism.

What a free runtime changes, and what it does not

When you leave a paid coding agent, generation quality is the discussion everyone wants, but process isolation is the discussion you actually need. A free server that can run tools makes the missing sandbox concrete, because commands now execute on a machine you operate. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source coding workspace with free model access and a free server option you can point at after the allowlist is frozen.

Keep the same fixtures when you change generators, including when you try that free-model path. If a run needs a wider allowlist, change the file in review rather than widening it in a prompt. Model choice becomes a smaller decision once permission lives in git. That is the useful leftover of this diary, and it still holds if you never touch that workspace.

Limitations

A prefix allowlist is not a container, a seccomp profile, or a multi-user isolation boundary for shared machines. It will not stop a binary you allowed from reading files through a feature you forgot to deny. It will not stop prompt injection from requesting a command that happens to match an allowed prefix. You still need operating-system isolation if the server is shared, networked, or holds secrets from other projects.

You should also keep secrets out of the working tree the policy allows for search tools. An allowlist that permits rg over a directory of tokens is a credential leak with extra steps. Move secrets into an environment the tool cannot print, and deny pagers that dump those values. If a fixture cannot be run without a secret in the tree, rewrite the fixture before you cut over.

Who should not use this approach

Skip this diary if your agent cannot run shell tools and only edits buffers through a mediated API. Skip it if you already run every tool inside a locked-down container with a reviewed seccomp or AppArmor profile. Skip it if you need a compliance certification, because this checker is a migration aid rather than an audit artifact. Teams that cannot fail closed should not pretend a YAML file is a sandbox, and they should stay on a mediated vendor host.

Once the policy exists, swapping generators is a smaller decision than it felt inside the paid UI. You can change models without changing permission, and that separation is the actual deliverable of this cutover. Keep widening the allowlist only through review, and treat every new binary as a migration leftover rather than a convenience. Exporting the chat history can wait until this fence is tested against the fixtures in your repository.

Top comments (0)