Back to Insights
AI & Machine Learning•AI Agents Are Shipping Code While You Sleep: A Developer's Field Guide to Sandboxing, Reviewing, and Auto-Approving Autonomous Code•tutorial•November 7, 2026•20 min read

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.

T
Tamiz UddinFull-Stack Engineer

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

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:

sql
┌─────────────┐
│  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:

dockerfile
# 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:

yaml
# 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: true makes the container's root filesystem immutable. The agent can only write to /workspace (the mounted repo) and /tmp.
  • cap_drop: ALL strips all Linux capabilities — the agent can't use mount, chown, or ptrace.
  • no-new-privileges: true prevents privilege escalation via setuid binaries.
  • internal: true on the network blocks outbound internet. If your agent needs to install packages, you'll need to allow a specific registry endpoint via extra_hosts and 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:

bash
# 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:

yaml
# .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: true on 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:

python
# 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:

python
# 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:

SignalActionWhy
High-severity vulnerabilityRejectNever merge known security holes
Test failuresRejectThe agent broke something
Type errorsRejectUnreliable code
Test-only changesEscalatePossible test manipulation
New dependencyEscalateSupply chain risk
> 800 additionsEscalateToo large to trust blindly
Lint errorsEscalateQuality issue, not fatal
All clearApproveSafe 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:

yaml
# .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:

python
# 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

MetricWhat 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-mergeHow much time you're actually saving
Post-merge revert rateThe most important one — how often do auto-merged PRs get reverted?
Test manipulation rateHow 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 secret in the path

You can encode this as a path-based rule in the policy engine:

python
# 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.