ux
Sandboxing Untrusted Tools In Dev Workflows Design Tradeoffs: practical systems guide
Technical field guide on sandboxing untrusted tools in dev workflows design tradeoffs for teams building dependable AI coding workflows.
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 false binary between isolation and usability
Security teams love to frame sandboxing as a simple on/off switch: either you run everything in strict containers with no host access, or you accept the risk. Real development workflows live in the messy middle. Consider three common scenarios:
A linter needs read access to your entire codebase but shouldn't modify files. A code formatter needs write access but only to tracked files. A language server needs to spawn background processes and cache state across sessions. Each requires different isolation boundaries, and overly strict defaults break all three.
The design trap is building one sandbox model and forcing every tool into it. Docker-in-Docker works for CI but adds 2-3 second startup overhead per command—fine for a 10-minute build, unusable for interactive edits. Lightweight namespaces like `unshare` avoid the startup cost but share the kernel, so a compromised tool can still read `/proc` and fingerprint your system.
Capability-based permissions vs allowlist hell
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.
Take Goatfied's constraint system as an example. Before an agent edits code, we run compile checks, linters, and tests—each potentially untrusted. We don't try to enumerate every binary they might spawn or file they might touch. Instead, we isolate the *outcomes*: tools run in ephemeral containers that can read the workspace and write to stdout/stderr, but their filesystem changes get diffed against the original state. Only changes to files matching the declared edit intent get promoted.
This flips the usual model. Instead of "what can this tool access," we ask "what changes do we care about keeping." A compromised linter could thrash a temp directory or leak your code to a remote server, but it can't sneak malicious code into your Git history because the diff review happens outside the sandbox.
The tradeoff: you still need to trust tools with confidentiality during their execution. If a tool exfiltrates your source code, capability isolation won't stop it. That's a different threat model requiring network sandboxing or air-gapped environments, which most teams can't operationalize for daily workflows.
Filesystem visibility and the invisible state problem
The hardest isolation boundary is filesystem state. 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.
We've found success with a hybrid approach: 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 example of the 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.
Networked tools and the caching paradox
Untrusted tools that need network access—package managers, remote formatters, cloud-based linters—create a catch-22. Deny network and they fail. Allow it and they can exfiltrate code or pull malicious payloads.
Transparent proxies that allowlist specific registries (npm, crates.io, PyPI) provide some middle ground. You can also record network activity and replay it in offline mode for later runs, essentially building a per-tool VCR cassette. This works well for reproducible CI builds but becomes painful when tools expect to check for updates or fetch new dependencies interactively.
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."
Audit logs as a safety net for insufficient isolation
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.
The implementation challenge is performance. Tracing every syscall via `strace` or eBPF adds measurable overhead. Sampling (log 1% of operations) reduces cost but creates gaps. Selective tracing—detailed logs for write operations, sampling for reads—balances the two but requires careful tuning per tool category.
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. 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.
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)
