agent-sandbox
# agent-sandbox
I built this to let an AI coding agent run real infrastructure commands
against a real Kubernetes cluster — without ever holding a standing
credential, and without being able to destroy anything unsupervised.
Three MCP tools. Every call runs inside a gVisor-sandboxed Kubernetes Job with a
short-lived, narrowly-scoped credential I have Vault mint for that single
action. Anything destructive stops at a human approval gate.
```
AI Agent (Claude Desktop / Cursor)
│ MCP protocol (stdio)
▼
┌──────────────────────────────────────────┐
│ MCP Server src/agent_sandbox │
│ 3 tools -> guardrails -> broker -> │
│ sandbox -> audit │
└───────┬──────────────────────┬───────────┘
│ │
▼ ▼
┌────────────────┐ ┌──────────────────────────┐
│ Credential │ │ Sandbox Runner │
│ Broker (Vault) │ │ K8s Job + gVisor │
│ 10-min leases │ │ restricted PSS │
│ per-action │ │ default-deny NetworkPolicy│
│ scope │ │ cpu/mem limits, deadline │
└────────────────┘ └──────────────────────────┘
│ │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Guardrails + Approval│
│ policy.yaml, SQLite, │
│ agent-sandbox CLI │
└──────────────────────┘
```
## Why I built this
An AI tool I was using once proposed a Terraform change against production
infrastructure that would have forced replacement of a live resource. The plan
looked routine. The failure mode wasn't the model being wrong — it was that
nothing sat between a plausible-looking plan and a destructive apply.
I built this project as the missing layer, in working code:
- the agent never holds a credential it could reuse
- everything runs somewhere it can't hurt the host
- destructive changes stop and wait for a person
- every action is on the record
`make demo` reproduces the exact scenario I ran into. A one-line label edit
forces replacement of a running Deployment, and the gate catches it.
## Quick start
Requires Docker, `kind`, `kubectl`, `vault`, `terraform`, and Python 3.11+.
```bash
brew install kind kubectl hashicorp/tap/vault terraform
make up # ~5 minutes from cold: cluster, CNI, gVisor, Vault, image, verify
make demo # the forces-replacement guardrail demo
make down # tear it all down
```
`make up` is idempotent. It finishes by running `make verify`, which *proves*
the isolation claims rather than asserting them (see below).
### Point an agent at it
```bash
cp examples/claude_desktop_config.json \
~/Library/Application\ Support/Claude/claude_desktop_config.json
```
Cursor: copy `examples/cursor_mcp.json` into `.cursor/mcp.json`. Then ask the
agent to *"check pod status in demo-app"* or *"plan the k8s-demo terraform"*.
## The three tools
| Tool | Risk | Behaviour |
|---|---|---|
| `k8s_get_pod_status(namespace)` | low | Runs immediately. Credential scoped to `get/list/watch pods` in one namespace. |
| `terraform_plan(working_dir)` | low | Runs immediately. Saves the plan so a later apply executes *exactly* the reviewed diff. |
| `terraform_apply(working_dir, approval_id?)` | **high** | Without `approval_id`: computes the plan, records a pending approval, applies nothing. With one: spends the approval and applies the saved plan. |
## The four components
### 1. Sandboxed execution — `src/agent_sandbox/sandbox.py`
One throwaway Job per tool call. Every control is there for a specific reason:
| Control | Prevents |
|---|---|
| `runtimeClassName: gvisor` | Syscalls hit the gVisor sentry, not the host kernel |
| PSS `restricted`, enforced by the API server | root, privilege escalation, capabilities, writable rootfs |
| `automountServiceAccountToken: false` | Any ambient cluster identity inside the sandbox |
| default-deny `NetworkPolicy` + API-server allowlist | Internet egress, lateral movement, metadata endpoints |
| `resources.limits`, `activeDeadlineSeconds` | A runaway job starving the node or hanging forever |
| `backoffLimit: 0` | A failed destructive action being silently retried |
The credential is mounted as a **file**, never an env var — env vars leak
through `kubectl describe`, `/proc`, and crash dumps.
### 2. Credential broker — `src/agent_sandbox/broker.py`
```python
cred = broker.issue_scoped_credential("k8s_get_pod_status")
# -> Vault mints a ServiceAccount + Role + RoleBinding, 10-minute lease
# -> revoked immediately after the Job finishes
```
- **The agent never picks its own scope.** Scope is derived from the action.
- **Deny by default.** An action with no mapped scope gets no credential.
- **Blast radius is enforced.** Requesting any namespace but the target is refused.
- **The token never leaves the module.** `Credential.__repr__` prints
`token=<redacted>`, so even an accidental log can't leak it.
I verified this by hand: a `pod-reader` token lists pods in `demo-app`, is
**denied** in `kube-system`, is **denied** on secrets, and stops working the
moment its lease is revoked — leaving no ServiceAccount behind.
### 3. Guardrails — `policy/policy.yaml`, `src/agent_sandbox/guardrails.py`
Deny by default: **registering an MCP tool is not enough to make it callable.**
A tool absent from the policy is refused, so adding capability requires a
deliberate risk-tier decision.
Approvals are hardened against the obvious attacks:
- **single-use** — consumed inside one SQLite transaction, so two concurrent
applies can't spend the same approval
- **parameter-bound** — bound to a hash of the exact tool + parameters, so an
approval for `k8s-demo` can't be replayed against `prod-cluster`
- **expiring** — 30 minutes by default
- **out-of-band** — granted via a separate CLI process. There is no MCP tool to
approve anything; the agent has no code path to approve its own request.
### 4. MCP server — `src/agent_sandbox/server.py`
Built on the official Python SDK (`mcp` 2.0, `MCPServer`). The transport layer
is deliberately thin and grants no authority of its own — a bug there cannot
widen what the agent can do, because the policy and the API server's Pod
Security admission are the actual controls.
## Audit log
Every call emits a correlated event trail to `var/audit.jsonl`:
```
tool.request -> guardrail.decision -> credential.issued -> sandbox.started
-> sandbox.completed -> credential.revoked -> tool.result
```
```bash
make audit
./.venv/bin/agent-sandbox audit --request-id req-4239b8bb5459 --json
```
Credential values are scrubbed recursively before write; scope, lease id and TTL
are kept. A test asserts no JWT-shaped string ever reaches the log.
## Verified, not assumed
Two things in this project are easy to *claim* and quietly not have, so I
didn't take them on faith. `make verify` tests both against the live cluster:
```
== 1. gVisor kernel check ==
kernel reported: Linux version 4.19.0-gvisor
PASS: sandbox runs on the gVisor sentry kernel
== 2. NetworkPolicy egress enforcement check ==
PASS: baseline connectivity works (got PONG)
PASS: default-deny egress enforced (traffic blocked)
```
**This caught a real problem while I was building it.** kind's default CNI
(kindnet) accepts `NetworkPolicy` objects and silently ignores them — I applied
a default-deny egress policy and pod-to-pod traffic still got through. The
sandbox would have looked locked down while having full network access. I
fixed it by disabling kindnet and installing Calico, which enforces for real.
See `scripts/install-calico.sh`.
I hit a related trap allowlisting the API server: the *ClusterIP* doesn't work,
because kube-proxy DNATs to the real endpoint before Calico evaluates egress.
The symptom was a sandbox that just hung with no policy-denied event to explain
it. Documented in `scripts/apply-sandbox-policy.sh`.
## Honest limitations
- **gVisor runs, but this is still kind.** I installed `runsc` inside the kind
node (a container in Docker Desktop's Linux VM) and verified it's active.
That's a real gVisor sandbox, not a production-hardened node.
- **The AWS/STS path is conditional.** `scripts/vault-setup.sh` only configures
Vault's AWS secrets engine when real AWS credentials are present; without
them it's skipped and says so. I didn't want to fake that path just to make
the demo look complete. The **live, demonstrable** credential path is the
Kubernetes one, which is fully real: dynamic ServiceAccounts, real RBAC,
real leases, real revocation.
- **Vault runs in dev mode** — in-memory, root token `root`, no seal. Fine for
a local project, not something I'd deploy as-is.
- **Destructive-signal detection is string matching** on plan output. It's a
*surfacing* aid for the human, not a security boundary — `terraform_apply` is
already high-tier and gated regardless of what the scan finds.
- **Single-node cluster**, so the PVC holding Terraform state is
`ReadWriteOnce` on one node.
## Layout
```
cluster/ kind config, RuntimeClass, namespaces, RBAC, network policy
images/ sandbox runner image (terraform + kubectl, providers vendored)
policy/ guardrail policy: risk tiers and destructive signals
scripts/ up/down, gVisor + Calico install, verification, demo
src/ the package: broker, sandbox, guardrails, approvals, audit, MCP
terraform/ demo module managed by the agent
tests/ 56 unit tests + a real-stdio MCP integration check
```
## Testing
```bash
make test # 56 unit tests, no cluster required
make test-mcp # drives the server over real MCP stdio (needs the stack up)
make verify # proves gVisor + NetworkPolicy enforcement on the live cluster
```
TDQS
Scored across 3 tools
Each tool has a clearly distinct purpose: read-only Kubernetes pod status, a Terraform plan diff, and a gated Terraform apply. The plan/apply pair is explicitly differentiated by the approval workflow, so an agent can reliably choose the right tool.
Names use consistent snake_case with a domain prefix and a verb (k8s_get_pod_status, terraform_plan, terraform_apply). The k8s tool adds an object noun while the Terraform tools do not, a minor deviation but still predictable.
Three tools across two distinct domains (Kubernetes and Terraform) is thin for an agent sandbox. Each tool earns its place, but the surface feels minimal relative to the apparent infrastructure-management scope.
Kubernetes coverage is limited to reading pod phase with no logs, events, describe, or any mutating operation, and Terraform lacks init/validate, destroy, and state inspection. The core plan-then-apply lifecycle is well covered, but notable gaps remain for a sandbox meant to work with real infrastructure.