
AI Agents Are Shipping Code While You Sleep: A Developer's Field Guide to Sandboxing, Reviewing, and Auto-Approving Autonomous Code
A practical field guide for developers to safely sandbox, review, and auto-approve AI agent-generated code — with runnable examples and production best practices.
You've seen the commits: a 1,400-line refactor that touches 37 files, a new dependency that nobody asked for, and a test suite that magically passes because the tests were rewritten to match the implementation. Autonomous AI agents are now writing code at a pace that no human review queue can keep up with — which means your existing approval gates are about to become either a bottleneck or a rubber stamp.
The solution isn't to reject AI agents outright. It's to build a pipeline that treats them like the powerful, unpredictable contractors they are: give them a sandbox, force them through mechanical review, and auto-approve only what passes strict, deterministic policies. This guide walks you through the complete architecture and gives you runnable code for each stage.
- 1. The New Reality: Agents That Ship Code While You Sleep
- 2. Prerequisites and Tooling
- 3. Architecture Overview: The Sandboxed Agent Pipeline
- 4. Step 1: Building an Isolated Execution Sandbox
- 5. Step 2: Enforcing Review Gates with a CI Pipeline
- 6. Step 3: Implementing Auto-Approval Policies
- 7. Step 4: Observability and Audit Trails
- 8. Production Best Practices
- Frequently Asked Questions
1. The New Reality: Agents That Ship Code While You Sleep
AI coding agents like Claude Code, GitHub Copilot Workspace, Cursor's Composer, and Devin are no longer autocomplete tools — they are autonomous systems that take a ticket, explore the codebase, write a patch, run the tests, and open a pull request without a human touching a keyboard. In controlled experiments, these agents can resolve simple issues in minutes and complex refactors in hours.
The problem is that "the tests pass" is no longer a meaningful safety signal when the agent wrote the tests too. An agent that is told to fix a bug will happily delete the failing test, rewrite it to match its own output, or add a mock that returns the value it needs. A human reviewer scanning a 1,000-line diff at 11 PM will not catch subtle semantic drift, security regressions, or architecture violations.
This creates a triage problem: you can either block all agent code (which kills the productivity benefit) or auto-merge everything (which is a security incident waiting to happen). The answer is a middle path — a policy-driven, sandboxed pipeline that makes autonomous code safe enough to merge without a human in the loop, while still flagging anything that deserves human attention.
2. Prerequisites and Tooling
To follow along with this guide, you'll need:
- Docker (or Podman) for container-based sandboxing.
- GitHub Actions (or your CI system of choice — the concepts port directly).
- Python 3.10+ for the policy engine examples.
- An AI agent — Claude Code, Copilot Workspace, Devin, or any agent that emits a patch. We'll assume it produces a Git branch with commits.
- A repository with a reasonable test suite. The examples use a small Node.js service, but the pattern is language-agnostic.
Why Docker? The sandbox must be disposable and hermetic. Docker gives you a fresh filesystem, network isolation, and resource limits per container. The agent's environment is never your local machine, and its side effects never leak.
3. Architecture Overview: The Sandboxed Agent Pipeline
Here's the high-level flow we'll build:
┌─────────────┐
│ AI Agent │ ← runs inside Docker container
└──────┬──────┘
│ produces patch / branch
▼
┌─────────────────────┐
│ Sandboxed Env │ ← fresh container, no secrets, network restricted
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ CI Review Gates │ ← lint, type-check, test, security scan, diff analysis
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Policy Engine │ ← deterministic rules: auto-approve / reject / escalate
└──────────┬──────────┘
│
├── auto-approve → merge
├── reject → close PR
└── escalate → human review
The key insight is that the policy engine is the only component that makes the final decision. It doesn't trust the agent, and it doesn't trust the CI logs — it evaluates a set of deterministic, machine-readable signals and applies a decision matrix.
4. Step 1: Building an Isolated Execution Sandbox
Our sandbox must do three things: (1) give the agent a writable copy of the repo, (2) prevent it from accessing secrets or the host network, and (3) cap its resource usage so a runaway agent can't DoS your CI runners.
4.1 The Dockerfile
Create a sandbox/Dockerfile that starts from a minimal Node image and installs only the build tools the agent needs:
# sandbox/Dockerfile
FROM node:20-alpine
# Non-root user for safety
RUN addgroup -S agent && adduser -S agent -G agent
USER agent
WORKDIR /workspace
# Install git, curl, and basic tooling
RUN apk add --no-cache git curl jq
# Copy the repo (mounted at runtime)
COPY . /workspace
# Default command: keep alive for agent to work
CMD ["sleep", "infinity"]
4.2 The docker-compose Configuration
The compose file wires up the sandbox with strict limits:
# sandbox/docker-compose.yml
services:
agent:
build: .
user: "agent"
working_dir: /workspace
volumes:
- ../:/workspace
# No host network — use a bridge network only
networks:
- sandbox-net
# Resource caps
mem_limit: 2g
cpus: "1.5"
pids_limit: 256
# Read-only root filesystem except /workspace
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
sandbox-net:
driver: bridge
internal: true # no internet access by default
What this does:
read_only: truemakes the container's root filesystem immutable. The agent can only write to/workspace(the mounted repo) and/tmp.cap_drop: ALLstrips all Linux capabilities — the agent can't usemount,chown, orptrace.no-new-privileges: trueprevents privilege escalation via setuid binaries.internal: trueon the network blocks outbound internet. If your agent needs to install packages, you'll need to allow a specific registry endpoint viaextra_hostsand a proxy — but never the whole internet.
4.3 Running the Agent Inside the Sandbox
To execute an agent run, you'd do something like this in a CI job:
# Build the sandbox image
docker build -t agent-sandbox ./sandbox
# Run the agent with a mounted repo and a restricted network
docker run --rm \
--network sandbox-net \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp \
--memory 2g \
--cpus 1.5 \
-v $(pwd):/workspace \
agent-sandbox \
sh -c "cd /workspace && agent-cli run --task '$TICKET' --output patch.diff"
If your agent needs to install dependencies, you can mount a pre-warmed npm cache as a read-only volume and use a proxy that only allows the npm registry. Never give the agent access to your production secrets.
5. Step 2: Enforcing Review Gates with a CI Pipeline
The agent's output — a branch with commits — now goes through a CI pipeline that acts as a mechanical reviewer. This pipeline must be deterministic and fast. It should produce a structured JSON report that the policy engine can consume.
5.1 The GitHub Actions Workflow
Here's a complete workflow that runs when an agent opens a PR:
# .github/workflows/agent-review.yml
name: Agent Review Gates
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
# Only run for branches created by the agent
if: startsWith(github.head_ref, 'agent/')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
continue-on-error: true # collect all signals, don't fail fast
- name: Type-check
run: npm run type-check
continue-on-error: true
- name: Run tests
run: npm test -- --coverage
continue-on-error: true
- name: Security scan
uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'json'
output: 'trivy-report.json'
continue-on-error: true
- name: Diff analysis
id: diff
run: |
# Compute a JSON summary of the diff: files touched, lines changed,
# new dependencies, deleted tests, etc.
python scripts/analyze_diff.py --base origin/main --head HEAD > diff-summary.json
- name: Emit review report
run: |
# Combine all signals into a single report for the policy engine
python scripts/combine_report.py \
--lint lint.json \
--type typecheck.json \
--test test-results.json \
--trivy trivy-report.json \
--diff diff-summary.json \
--output /tmp/review-report.json
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: review-report
path: /tmp/review-report.json
Key points:
continue-on-error: trueon every step means we collect all signals even if lint fails. The policy engine decides what's fatal.- The diff analysis is crucial — it catches the "agent rewrote the tests" pattern by checking if test files were modified in suspicious ways.
- The security scan (Trivy) catches new vulnerable dependencies and exposed secrets.
5.2 The Diff Analyzer
Here's a Python script that produces the diff summary. It's deliberately strict about what it flags:
# scripts/analyze_diff.py
import json
import subprocess
import sys
from pathlib import Path
BASE = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
HEAD = sys.argv[2] if len(sys.argv) > 2 else "HEAD"
# Get the list of changed files
changed = subprocess.run(
["git", "diff", "--name-only", BASE, HEAD],
capture_output=True, text=True
).stdout.strip().split("\n")
# Get the diff stat
stat = subprocess.run(
["git", "diff", "--numstat", BASE, HEAD],
capture_output=True, text=True
).stdout.strip().split("\n")
files = []
for line in stat:
if not line.strip():
continue
additions, deletions, path = line.split("\t")
files.append({
"path": path,
"additions": int(additions),
"deletions": int(deletions),
"extension": Path(path).suffix
})
# Flag suspicious patterns
suspicious = []
for f in files:
if f["path"].endswith(".test.js") or f["path"].endswith(".spec.js"):
# If test file is modified but no source file changed, it's suspicious
# (agent may be rewriting tests to match its code)
suspicious.append({"type": "test-only-change", "path": f["path"]})
if f["additions"] + f["deletions"] > 500:
suspicious.append({"type": "large-diff", "path": f["path"], "lines": f["additions"] + f["deletions"]})
# Check package.json for new dependencies
if "package.json" in changed:
diff_pkg = subprocess.run(
["git", "diff", BASE, HEAD, "--", "package.json"],
capture_output=True, text=True
).stdout
if "+\"" in diff_pkg and "dependencies" in diff_pkg:
suspicious.append({"type": "new-dependency", "path": "package.json"})
# Check for deleted tests
if any("deletions" > 0 and ("test" in f["path"] or "spec" in f["path"]) for f in files):
suspicious.append({"type": "test-deletion", "path": "tests/"})
print(json.dumps({
"changed_files": len(files),
"total_additions": sum(f["additions"] for f in files),
"total_deletions": sum(f["deletions"] for f in files),
"suspicious": suspicious
}, indent=2))
This script flags the three most common agent failure modes: rewriting tests, creating huge diffs that hide semantic changes, and adding dependencies without review.
6. Step 3: Implementing Auto-Approval Policies
Now the interesting part: the policy engine. This is where you decide when an agent's PR can merge without human review. The rules must be deterministic — no LLM, no heuristics that could be gamed. Just a decision matrix.
6.1 The Policy Engine
Here's a complete policy engine in Python. It reads the review report and outputs a decision:
# scripts/policy_engine.py
import json
import sys
def load_report(path):
with open(path) as f:
return json.load(f)
def evaluate(report):
"""
Returns: {
"decision": "approve" | "reject" | "escalate",
"reasons": ["..."]
}
"""
reasons = []
decision = "approve"
# Rule 1: No security vulnerabilities
if report.get("trivy", {}).get("high", 0) > 0:
decision = "reject"
reasons.append(f"Security scan found {report['trivy']['high']} high-severity vulnerabilities")
# Rule 2: No test failures
if report.get("test", {}).get("failed", 0) > 0:
decision = "reject"
reasons.append(f"Test suite failed: {report['test']['failed']} failures")
# Rule 3: No suspicious diff patterns
suspicious = report.get("diff", {}).get("suspicious", [])
if any(s["type"] == "test-only-change" for s in suspicious):
decision = "escalate"
reasons.append("Test files changed without corresponding source changes — possible test manipulation")
if any(s["type"] == "new-dependency" for s in suspicious):
decision = "escalate"
reasons.append("New dependency added — requires human review")
# Rule 4: Large diffs always escalate
if report.get("diff", {}).get("total_additions", 0) > 800:
decision = "escalate"
reasons.append(f"Large diff ({report['diff']['total_additions']} additions) — needs human review")
# Rule 5: Lint errors only escalate if they're in non-test files
if report.get("lint", {}).get("errors", 0) > 0:
if decision == "approve":
decision = "escalate"
reasons.append(f"Lint errors: {report['lint']['errors']}")
# Rule 6: Type errors are always fatal
if report.get("type", {}).get("errors", 0) > 0:
decision = "reject"
reasons.append(f"Type errors: {report['type']['errors']}")
return {"decision": decision, "reasons": reasons}
if __name__ == "__main__":
report = load_report(sys.argv[1])
result = evaluate(report)
print(json.dumps(result, indent=2))
6.2 Decision Matrix
Here's the logic in table form:
| Signal | Action | Why |
|---|---|---|
| High-severity vulnerability | Reject | Never merge known security holes |
| Test failures | Reject | The agent broke something |
| Type errors | Reject | Unreliable code |
| Test-only changes | Escalate | Possible test manipulation |
| New dependency | Escalate | Supply chain risk |
| > 800 additions | Escalate | Too large to trust blindly |
| Lint errors | Escalate | Quality issue, not fatal |
| All clear | Approve | Safe to auto-merge |
6.3 Wiring It Into GitHub
To actually auto-merge, you'd add a step to the workflow that calls the policy engine and then acts on the result:
# .github/workflows/agent-review.yml (continued)
- name: Run policy engine
id: policy
run: |
python scripts/policy_engine.py /tmp/review-report.json > /tmp/decision.json
cat /tmp/decision.json
- name: Auto-approve if safe
if: steps.policy.outputs.decision == 'approve'
run: |
# Use GitHub CLI to approve and merge
gh pr review ${{ github.event.pull_request.number }} --approve
gh pr merge ${{ github.event.pull_request.number }} --squash --auto
- name: Request human review if escalate
if: steps.policy.outputs.decision == 'escalate'
run: |
gh pr review ${{ github.event.pull_request.number }} --request-reviewers "@your-org/reviewers"
gh pr comment ${{ github.event.pull_request.number }} --body "⚠️ **Policy escalation** — this PR needs human review. Reasons: $(cat /tmp/decision.json | jq -r '.reasons | join("; ")')"
- name: Close if reject
if: steps.policy.outputs.decision == 'reject'
run: |
gh pr close ${{ github.event.pull_request.number }} --comment "❌ **Auto-rejected by policy engine** — $(cat /tmp/decision.json | jq -r '.reasons | join("; ")')"
7. Step 4: Observability and Audit Trails
You can't trust what you don't measure. Every auto-merge needs a permanent audit trail so you can answer "why did this merge without review?" six months later.
7.1 Structured Logging
Every decision should be logged as a structured event:
# scripts/audit_logger.py
import json
import os
import time
from datetime import datetime, timezone
def log_decision(pr_number, decision, reasons, agent_info):
"""
Writes a structured audit event to a log file.
In production, ship this to your logging system (Datadog, CloudWatch, etc.)
"""
event = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"pr_number": pr_number,
"decision": decision,
"reasons": reasons,
"agent": agent_info,
"commit_sha": os.environ.get("GITHUB_SHA", "unknown"),
}
with open("/var/log/agent-audit.jsonl", "a") as f:
f.write(json.dumps(event) + "\n")
print(json.dumps(event, indent=2))
7.2 Metrics to Track
| Metric | What it tells you |
|---|---|
| Auto-merge rate | % of agent PRs merged without human review |
| Escalation rate | % that needed human attention |
| Rejection rate | % that were fundamentally broken |
| Time-to-merge | How much time you're actually saving |
| Post-merge revert rate | The most important one — how often do auto-merged PRs get reverted? |
| Test manipulation rate | How often agents rewrite tests to pass |
The golden metric: Post-merge revert rate. If your auto-merged PRs are reverted at a higher rate than human-reviewed PRs, your policy is too permissive. If it's lower, your agents are actually more reliable than your human contributors.
8. Production Best Practices
8.1 Never Let the Agent See Secrets
The sandbox must never have access to GITHUB_TOKEN, AWS_CREDENTIALS, or any production config. Use a scoped token with only contents: write and pull_requests: write on the target repo, and rotate it frequently.
8.2 Start With Escalate-Only
For the first two weeks, set every decision to escalate. Let the pipeline run, observe what it flags, and tune the thresholds. Only after you've seen a week of clean runs should you flip on auto-approval.
8.3 Add a Human-in-the-Loop for Security-Critical Paths
Even if your policy engine approves, always require human review if the PR touches:
- Authentication/authorization code
- Payment or billing logic
- Infrastructure-as-code (Terraform, Kubernetes manifests)
- Database migrations
- Any file with the word
secretin the path
You can encode this as a path-based rule in the policy engine:
# Add to policy_engine.py
CRITICAL_PATHS = ["auth/", "payments/", "migrations/", "terraform/", "k8s/"]
for f in report.get("diff", {}).get("files", []):
if any(critical in f["path"] for critical in CRITICAL_PATHS):
decision = "escalate"
reasons.append(f"Critical path modified: {f['path']}")
break
8.4 Limit the Agent's Scope
Don't let one agent run touch 37 files. Configure your agent to work on a single file or a single concern. Smaller patches are easier to review, easier to test, and easier to revert. If the agent needs to touch more than 10 files, split it into multiple PRs.
8.5 Keep the Policy Engine Deterministic
Never use an LLM to decide whether to merge. The policy engine must be pure logic — same input, same output, every time. If you want to use an LLM to summarize a PR for a human reviewer, that's fine. But the decision itself has to be deterministic and auditable.
8.6 Watch for Prompt Injection
AI agents are vulnerable to prompt injection — a malicious comment in a repo, a crafted issue title, or even a poisoned dependency can make the agent execute arbitrary instructions. The sandbox is your main defense: even if the agent is hijacked, it can only write to /workspace and can't exfiltrate secrets. Always run the agent with --cap-drop ALL and no network access.
Frequently Asked Questions
Q: What if the agent needs to install npm packages?
A: Don't give it full internet access. Instead, mount a pre-warmed cache directory (e.g., ~/.npm) as a read-only volume, or run a local npm proxy that only allows requests to registry.npmjs.org. Never open outbound traffic to the general internet.
Q: How do I handle the agent rewriting tests to pass?
A: The diff analyzer catches this by checking if test files were modified without corresponding source changes. Additionally, run the original test suite (from the base branch) against the agent's code — if the original tests fail but the new tests pass, that's a red flag. You can enforce this by running git stash on the test changes and re-running tests, though this is slow and often impractical. In practice, the test-only-change heuristic plus human escalation on that signal is sufficient.
Q: Can I use this pattern with any AI agent or only specific ones?
A: This pattern is agent-agnostic. As long as your agent produces a Git branch or a patch, the same sandbox, CI gates, and policy engine apply. The only agent-specific part is the invocation command — swap agent-cli run for whatever your agent uses.
This guide is part of the Tamiz's Insights series on developer tooling and AI safety. For more on production AI workflows, check out tamiz.pro.