Gustav Larsson

How I sandbox LLMs during development

I have some opinions:

Therefore, asking for permission to run commands and trying to maintain a list of allowed commands is both wasteful and probably unsafe. I prefer sandboxing and allowing full freedom for the LLM agent.

(While I could rely on the sandbox mechanism of the LLM agent program itself, I would rather have the control myself)

I use bubblewrap on linux to expose only the current repo to the agent.

What this should protect against:

What this does not protect against:

The script

ccbwrap.py

#!/usr/bin/env python3

import os
import sys
import subprocess
from pathlib import Path

def main():
    hostpwd = os.getcwd()

    if not Path('.git').is_dir():
        print('must be run in root of git repo', file=sys.stderr)
        sys.exit(1)

    home = os.environ['HOME']
    colorterm = os.environ.get('COLORTERM', 'rxvt-xpm')
    term = os.environ.get('TERM', 'xterm')

    bwrap_cmd = [
        'bwrap',
        '--unshare-all',
        '--share-net',
        '--ro-bind', '/bin', '/bin',
        '--ro-bind', '/lib', '/lib',
        '--ro-bind', '/lib64', '/lib64',
        '--ro-bind', '/var', '/var',
        '--ro-bind', '/etc', '/etc',
        '--tmpfs', '/run',
    ]

    if Path('/run/systemd/resolve').exists():
        bwrap_cmd.extend(['--ro-bind', '/run/systemd/resolve', '/run/systemd/resolve'])

    bwrap_cmd.extend([
        '--dev-bind', '/dev', '/dev',
        '--ro-bind', '/usr', '/usr',
        '--tmpfs', '/tmp',
        '--proc', '/proc',
    ])

    # Determine whether to sandbox the entire repo or a single file.
    single_file = os.getenv('FILE')
    if single_file:
        single_file = Path(single_file)
        if not single_file.exists():
            print(f"Error: file {single_file} does not exist", file=sys.stderr)
            sys.exit(1)
        bwrap_cmd.extend([
            '--bind', single_file.absolute(), Path(home)  / 'workdir' / single_file.name,
            '--chdir', Path(home) / 'workdir',
        ])
    else:
        bwrap_cmd.extend([
            '--bind', hostpwd, hostpwd,
            '--chdir', hostpwd,
        ])
    bwrap_cmd.extend([
        '--clearenv',
        '--setenv', 'HOME', home,
        '--setenv', 'COLORTERM', colorterm,
        '--setenv', 'TERM', term,
    ])

    # Pass through these env vars if set in the host environment
    passthrough_env_vars = [
        'ANTHROPIC_API_KEY',
        'ANTHROPIC_BASE_URL',
        'ANTHROPIC_DEFAULT_SONNET_MODEL',
        'ANTHROPIC_DEFAULT_HAIKU_MODEL',
    ]

    for var in passthrough_env_vars:
        val = os.environ.get(var)
        if val is not None:
            bwrap_cmd.extend(['--setenv', var, val])

    bwrap_cmd.extend([
        '--die-with-parent',
        # --new-session prevents injecting characters back into the terminal
        # https://github.com/containers/bubblewrap?tab=readme-ov-file#limitations
        '--new-session',
    ])

    optional_ro_bindings = [
        f'{home}/bin',
        f'{home}/apps',
        f'{home}/.vimrc',
        f'{home}/.bashrc',
        f'{home}/.bashrc.local',
        f'{home}/.profile',
        f'{home}/.cargo',
        f'{home}/.rustup',
        f'{home}/.gitconfig',
        f'{home}/.local/bin',
        f'{home}/.local/share/uv',
        f'{home}/.nvm',
        f'{home}/.local/share/claude',
    ]

    for path in optional_ro_bindings:
        if Path(path).exists():
            bwrap_cmd.extend(['--ro-bind', path, path])

    # Add writable bindings (only if they exist)
    writable_bindings = [
        f'{home}/.claude',
        f'{home}/.claude.json',
    ]

    for path in writable_bindings:
        if Path(path).exists():
            bwrap_cmd.extend(['--bind', path, path])

    # Add the command to execute inside the sandbox
    bwrap_cmd.extend([
        'bash', '-l', '-e', '-c', f'{home}/.local/bin/claude --dangerously-skip-permissions "$@"', '--', *sys.argv[1:],
    ])

    subprocess.run(bwrap_cmd, check=True)

if __name__ == '__main__':
    main()