security
Sandboxing untrusted tool calls in developer workflows
Sandboxing untrusted tool calls isolates AI agent actions from production systems, preventing destructive commands through containerization and permission boundaries.

When you give an AI agent permission to run shell commands, install packages, or call external APIs, you're essentially handing car keys to an intern who learned to drive from a neural network. Most of the time it works fine. Sometimes it tries to rm -rf your home directory because a markdown linter suggested it would "clean up inconsistent whitespace."
The standard industry response is to cross your fingers and log everything. A better approach is to assume the agent will eventually do something catastrophic—not because it's malicious, but because language models hallucinate, tools have bugs, and entropy wins—and design your workflow so catastrophic actions simply can't execute.
Why production-grade sandboxing matters for AI workflows
Traditional CI/CD security assumes humans wrote the code and reviewed the changes. You can rely on branch protection, required approvals, and pre-commit hooks because a person with context made deliberate decisions. AI agents operate differently: they generate and execute code in a tight loop, often without human review until after the fact.
This creates three specific risks that standard security controls miss:
1. Rapid iteration compounds mistakes. An agent that writes a buggy database migration might immediately run it, realize the schema is broken, write a "fix" migration that makes things worse, and loop through five destructive iterations before you notice.
2. Credential access is implicit. If your agent runs in an environment with AWS credentials, SSH keys, or API tokens, it can use them. It doesn't need to steal secrets—it already has ambient authority.
3. Tool hallucination is real. LLMs occasionally invent command flags, misremember API signatures, or confidently suggest curl | sudo bash installations. Even well-prompted agents make dangerous calls that pass basic validation but fail in production.
The goal isn't to eliminate risk entirely (impossible) or to prevent the agent from doing useful work (pointless). The goal is to make sure when things go wrong, they go wrong in a contained, reversible, observable way.
Network isolation and read-only filesystems
The simplest effective sandbox technique is to run agent tool execution in an isolated environment with restricted capabilities. This doesn't require complex security frameworks—Linux namespaces and container runtimes already provide the primitives.
For file operations, mount the workspace with read-only access by default, then selectively allow writes to specific directories:
docker run \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=100m \
--volume $(pwd)/workspace:/workspace:ro \
--volume $(pwd)/output:/workspace/output:rw \
agent-sandbox
This lets the agent read your codebase, install temporary dependencies in /tmp, and write results to the output directory, but it can't modify source files, overwrite build artifacts, or touch anything outside its designated scope.
For network access, use dedicated network namespaces with explicit allow-lists. If your agent needs to hit api.github.com and registry.npmjs.org but nothing else, configure that at the network layer rather than relying on the agent to respect logical boundaries:
sandbox:
network:
mode: isolated
allowlist:
- api.github.com:443
- registry.npmjs.org:443
- *.cloudfront.net:443 # For CDN-hosted packages
This prevents accidental or hallucinated calls to internal services, production databases, or random third-party APIs. If the agent tries to curl https://sketchy-site.example, it fails at the network level before any data moves.
Capability-based execution with explicit grants
Rather than giving the agent a shell with full environment access, decompose available actions into discrete capabilities and grant them explicitly. This is how Goatfied's agent loop structures tool calls: each tool has a declared schema, required parameters, and explicit scope.
Instead of exposing subprocess.run() with arbitrary command execution, provide higher-level operations:
const tools = {
install_package: {
description: "Install npm package in isolated node_modules",
params: { name: string, version?: string },
sandbox: { network: true, filesystem: { write: ["/workspace/node_modules"] } }
},
run_tests: {
description: "Execute test suite with timeout",
params: { path: string, timeout: number },
sandbox: { network: false, filesystem: { read: ["/workspace"] } }
},
write_file: {
description: "Write content to workspace file",
params: { path: string, content: string },
sandbox: { network: false, filesystem: { write: ["/workspace/src"] } }
}
}
Each tool declares what it needs. The runtime enforces these constraints before execution, not by trusting the agent to follow rules, but by making violations impossible at the container or syscall level.
This approach also makes audit trails actionable. Instead of logs showing bash -c "some complex pipeline", you see structured tool calls: install_package(name="lodash", version="4.17.21") followed by run_tests(path="./test", timeout=30). When something breaks, you know exactly which capability was used and with what parameters.
Timeouts, retries, and circuit breakers
Sandboxing isn't just about preventing bad outcomes—it's also about preventing infinite loops and resource exhaustion. AI agents don't naturally stop when they're stuck. They'll retry failed operations, spawn additional processes, and consume tokens until you hit API limits or run out of memory.
Hard timeouts at the tool level provide a forcing function:
async function executeWithTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
cleanupFn?: () => void
): Promise<T> {
const timeoutHandle = setTimeout(() => {
cleanupFn?.();
throw new Error(`Tool execution exceeded ${timeoutMs}ms timeout`);
}, timeoutMs);
try {
return await fn();
} finally {
clearTimeout(timeoutHandle);
}
}
Set per-tool timeouts based on expected behavior. A linter should finish in seconds. A full test suite might take minutes. If a tool runs longer than its timeout, kill the process, clean up resources, and surface the failure to the agent with context about why it was terminated.
Circuit breakers prevent retry storms when a tool consistently fails:
class CircuitBreaker {
private failures = 0;
private readonly threshold = 3;
private readonly resetTimeMs = 60000;
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.failures >= this.threshold) {
throw new Error("Circuit breaker open: too many failures");
}
try {
const result = await fn();
this.failures = 0; // Reset on success
return result;
} catch (error) {
this.failures++;
setTimeout(() => this.failures = 0, this.resetTimeMs);
throw error;
}
}
}
If the agent tries to run a broken linter three times in a row, the circuit opens and subsequent calls fail immediately. This gives you time to investigate the root cause instead of burning through API credits on repeated failures.
Observable boundaries and human checkpoints
Even with robust sandboxing, you need visibility into what the agent is doing and checkpoints where humans can intervene. Goatfied's compile/lint/test gates serve this function: before code leaves the sandbox, it must pass static analysis and automated checks. If tests fail, the agent sees the failure output and can retry, but broken code never merges.
For higher-risk operations—database migrations, infrastructure changes, credential management—require explicit approval:
if (tool.requiresApproval) {
const approval = await requestHumanApproval({
tool: tool.name,
params: tool.params,
risk: tool.riskLevel,
context: agent.currentTask
});
if (!approval.granted) {
return { status: "rejected", reason: approval.reason };
}
}
This doesn't slow down routine work—the agent can still write code, run tests, and iterate freely—but it creates a mandatory checkpoint for operations that cross security boundaries or modify production state.
Logging every tool call with full parameters and execution context makes the approval process informed. Reviewers see not just "agent wants to run migration" but "agent wrote migration to add index on users.email, tested locally, no conflicts detected, last 3 migrations succeeded."
Combining sandboxing with the agent loop
Sandboxing works best when integrated into the agent's feedback loop rather than bolted on afterward. In Goatfied's plan → constrain → edit → validate → retry flow, constraints include both logical rules (style guides, architectural patterns) and security boundaries (sandboxed execution, capability limits).
The agent proposes an action: "install package X and run tests." The constraint phase checks: Does this tool have network access? Is the package on the allowlist? Are tests sandboxed? The edit phase generates the actual commands. The validate phase runs them in an isolated container, captures output, and checks exit codes. If validation fails, the retry phase gets structured feedback: "test execution timed out after 30s, stderr: [output]."
This structure makes sandboxing part of the normal workflow, not an emergency brake. The agent learns through experience which operations succeed in the sandbox and adjusts its approach. It doesn't need to understand Linux namespaces or container security—it just sees which tool calls work and which fail, then adapts.