Skip to main content
Glama
README.md
# 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

A3.9/5.0

Scored across 3 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count3/5

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.

Completeness3/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues