Agent security: running untrusted code safely
AI agents that write and execute code, inside gVisor sandboxes, holding zero secrets, mediated by an audited MCP broker on the host.
In Part 2, I described an interactive agentic environment: OpenCode with MCP servers and skills. But what happens when agents run unattended? What about scheduled maintenance agents, automated code review bots, or ops agents that triage production issues?
An unattended AI agent that writes code, runs terminal commands, and accesses external APIs is an autonomous remote code execution engine with network access. In security terms, it's the nightmare scenario. Put your cloud credentials inside that agent, and one prompt injection later your production infrastructure is compromised.
Pattern 2: zero-secret architecture
The solution is a security model I call Pattern 2 (documented in nix/autonomous-agents-design.md):
The agent process holds zero secrets. A control plane (MCP broker) on the host holds every credential and mediates every privileged action. The agent can only do what the broker exposes as a narrow, audited tool.
A note on status: what follows describes a validated design and a written implementation plan, not a system that's running yet. As of this writing, none of the implementation milestones (broker, sandbox image, egress proxy, token minter, supervisor) exist on disk. The architecture, tool list, and lifecycle below are the target this design commits to, to be verified against the checklist further down before go-live.
┌───────────────────────── HOST (trusted) ─────────────────────────┐
│ │
│ MCP-tool BROKER (control plane) — holds ALL credentials │
│ Tools: mysql_ro_query · cloudwatch_get_logs · sqs_peek_dlq │
│ jira_read · jira_create_ticket · github_open_pr │
│ write_handoff_doc │
│ Audit log: every call logged (who/what/when/result) │
│ │
│ ▲ Unix domain socket (bind-mounted into sandbox) │
│ │ │
│ Egress allowlist proxy — bedrock-runtime + registries/git only │
│ Bedrock token minter — scoped AssumeRole → short-term API key │
│ Supervisor (systemd --user) — spawn/scope/teardown per job │
│ │
│ ┌──────────── gVisor sandbox (runsc, untrusted) ────────────┐ │
│ │ agent runtime + restricted tool manifest │ │
│ │ env: AWS_BEARER_TOKEN_BEDROCK (short-lived, scoped) │ │
│ │ FS: one scoped bind-mount (code agent) / none (ops) │ │
│ │ NET: closed except broker socket + allowlisted egress │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Threat model and mitigations
| Vector | Mitigation |
|---|---|
| Prompt injection (from repo/Jira/Slack) | Manual-only triggers. Injected text is data, never a trigger. Restricted tool manifest limits blast radius. |
| Code-generation exploits | gVisor syscall isolation; ephemeral sandbox; no host FS beyond one scoped share. |
| Secret exfiltration | Zero secrets in sandbox. Only a short-lived, scoped Bedrock API key. |
| Context poisoning | Ephemeral per-job sandbox; no persistent agent memory across jobs. |
| Tool abuse | Broker enforces read-only + scope + per-action policy; high-risk actions hard-blocked. |
| Data exfil via network | Default-deny egress; allowlist = Bedrock runtime + package registries/git host. |
The two agents
Two autonomous agents are planned, each with a distinct capability matrix.
Code agent
| Purpose | Code work on local repos + GitHub |
| Reads | One repo's source tree (bind-mounted) |
| Writes (allowed) | Repo edits, auto-commit, feature branch, open PR |
| Writes (forbidden) | Merge, push to main, force-push |
| Secrets held | None (Bedrock token only, scoped to bedrock:InvokeModel) |
| Network | Broker socket + Bedrock + registries/git host |
Ops agent
| Purpose | Triage: read Jira/Slack, query infra read-only, draft handoffs/tickets |
| Reads | Everything via broker (Jira, Slack, MySQL RDS RO, CloudWatch, SQS DLQs) |
| Writes (allowed) | Create Jira tickets + local handoff docs |
| Writes (forbidden) | Any Slack/email post, any DB write, any infra mutation |
| Secrets held | None (Bedrock token only) |
| Network | Broker socket + Bedrock only |
The MCP broker: control plane
The broker is designed to expose exactly 7 tools. Each has per-action enforcement:
| Tool | Used by | Enforcement |
|---|---|---|
mysql_ro_query |
ops | Read-only DB user; statement allowlist (SELECT/SHOW/EXPLAIN); row/time caps |
cloudwatch_get_logs |
ops | Read-only IAM; scoped log groups |
sqs_peek_dlq |
ops | receive-without-delete (peek only); scoped DLQ ARNs |
jira_read |
ops | Read-only Jira token |
jira_create_ticket |
ops | Create-only; project allowlist; no transitions/comments to live channels |
github_open_pr |
code | Open-PR-only token; no merge, no push to main |
write_handoff_doc |
ops | Writes to a host review dir only |
Hard-blocked everywhere: Slack post, email send, DB write, infra mutation, git merge, force-push, push to protected branches.
The design requires every tool call to be appended to a structured audit log: agent ID, tool name + params, timestamp, result (success/failure + truncated response). The broker is specified to refuse serving calls that bypass the logger — this is a design requirement, not yet a built and tested guarantee.
LLM inference: session-scoped Bedrock API key
The most important design decision here is how the sandboxed agent calls the LLM.
Rejected option: pass a long-lived API key through the broker. This would require the broker to proxy every LLM request or trust the agent with a key.
Selected approach: at job start, the host mints a short-term Amazon Bedrock API key and injects it into the sandbox as AWS_BEARER_TOKEN_BEDROCK. The key is crafted via a scoped AssumeRole session:
const { Credentials } = await sts.assumeRole({
RoleArn: "arn:aws:iam::...:role/agent-bedrock-minter",
RoleSessionName: `agent-${jobId}`,
Policy: {
"Version": "2012-10-17",
"Statement": [{
Effect: "Allow",
Action: [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
Resource: [
"arn:aws:bedrock:eu-central-1::foundation-model/anthropic.claude-sonnet-4-*"
]
}]
},
DurationSeconds: 43200 // 12 hours or job length, whichever shorter
});
Why it's safe: the key only authenticates Bedrock runtime. A leaked key cannot pivot to RDS, CloudWatch, SQS, or S3. The key lives for at most 12 hours, or however long the job runs. The session policy restricts to specific model ARNs, so the agent can't invoke arbitrary models. CloudWatch billing alarms and model-invocation logging are the planned mitigations for token-cost DoS and prompt exfiltration — not yet wired up (they're tracked as residual-risk hardening in the implementation plan).
Isolation layer: gVisor
The sandboxing technology is gVisor: a user-space kernel that intercepts every syscall from the agent process. Unlike Docker containers (which share the host kernel), gVisor provides a second kernel boundary:
docker run --runtime=runsc \
--rm \
-v /path/to/repo:/workspace:ro \
-e AWS_BEARER_TOKEN_BEDROCK=$TOKEN \
agent-image \
run-task.sh
By design, ~/.aws, ~/.ssh, ~/.work, and the host /home/ should not be reachable from inside the sandbox. Egress should be denied to everything except the allowlist. The Bedrock token should not be able to call a non-Bedrock AWS API (s3 ls should return denied). Forbidden tools should be absent or blocked. None of this is a claim about a running system yet — it's exactly what the checklist below exists to verify before go-live.
Verification checklist (before go-live)
□ Confirm ~/.aws, ~/.ssh, ~/.work are NOT reachable from inside sandbox
□ Confirm only the scoped repo bind-mount is visible
□ Confirm egress to arbitrary hosts fails
□ Confirm Bedrock token cannot call S3/EC2/RDS
□ Confirm forbidden git commands are blocked
□ Confirm every broker call lands in the audit log
Job lifecycle
This is the planned lifecycle — the sequence the supervisor is designed to enforce once built:
1. TRIGGER: Manual / scheduler only
(No Jira/Slack polling — external text is data, never a trigger)
2. SPAWN: Supervisor mints scoped Bedrock token
→ starts fresh gVisor sandbox
→ bind-mounts scoped share
→ opens broker socket via bind-mounted Unix domain socket
→ closes all other egress
3. RUN: Agent works within restricted tool manifest
→ LLM calls go directly to Bedrock (no broker bottleneck)
→ All privileged actions go through broker (audited)
4. ARTIFACTS:
Code agent: auto-commits to feature branch → opens PR (never merges)
Ops agent: auto-creates Jira tickets → writes handoff docs to review dir
5. TEARDOWN: Sandbox destroyed
→ Bedrock token expires
→ Outputs collected to audit log
→ Ephemeral: nothing to poison next run
The review boundary is the merge step (code) and ticket triage (ops), both human and out-of-band.
Phase 2: Firecracker microVMs
gVisor is Phase 1. The design document already describes Phase 2: Firecracker microVMs.
Firecracker is AWS's open-source VM manager (the technology behind Lambda and Fargate). Each agent runs in its own microVM with hardware virtualisation via KVM — stronger isolation than gVisor's user-space kernel. No shared kernel surface at all. Virtio-fs handles shared filesystems; vsock replaces the Unix domain socket for host-guest communication.
The repo already has a declarative Firecracker VM configuration — but for a different, already-existing system: the Hermes agent (nix/vms/hermes/), not the autonomous-agent runner described above. That config defines the TAP networking, virtio-fs shares, and systemd lifecycle management a Firecracker microVM needs on this hardware, and it's a useful template for what Phase 2 of the autonomous-agent runner will need. It hasn't been switched to and run on real hardware yet, though — it lives inside nixosConfigurations.default, the full NixOS config for the Linux desktop, which (as covered in Part 5) that machine hasn't adopted; it's still running Fedora with home-manager layered on top. So "the transition from gVisor to Firecracker" isn't tested yet — what exists is a config that hasn't been booted.
Deployment: NixOS systemd services
The plan is to declare the entire autonomous agent system in Nix:
# Planned in nix/home/autonomous-agents.nix
services.agent-supervisor = {
enable = true;
agents = {
code = {
sandbox = "runsc"; # or "firecracker" in Phase 2
repo-bind = "/home/developer/repos/work-project";
schedule = "daily 06:00";
};
ops = {
sandbox = "runsc";
schedule = "daily 07:00";
};
};
};
The supervisor is designed to run as a systemd --user service, with per-agent sandboxes spawned as transient systemd scopes for resource accounting.
What Pattern 2 enables
This architecture is the result of years of layered infrastructure. Without Nix, reproducing the sandbox image, broker, and systemd units across machines would be impractical. Without the agentic stack from Part 2, the broker MCP protocol would be foreign. Without the security posture from Part 3, the sops-nix integration and audit logging would require separate infrastructure.
In Part 5, we'll look at what's next: the Hermes agent system, full NixOS switch, skill architecture improvements, and the longer-term vision.