Tracekit Box: Running AI Agents in Micro-VMs Where Every Way Out Is Decided and Signed
Organizations: Cygnux Labs
Abstract
AI agents now install packages, call model APIs with their operators' keys and change code in repositories that deploy to production. The network, credentials and write access they need are also how they can leak data, steal a key or change a system nobody approved. Sandboxes contain an agent but leave little evidence of what crossed their boundary; tracing records what happened but contains nothing. We present Tracekit Box, an open-source system (MIT, v0) that does both. Each job runs in its own Firecracker micro-VM with no network card. The only ways out are doors on the host, served over vsock: commands, outbound connections, secrets, model calls and the workspace patch. At every door a Tracekit signer the agent cannot reach decides what passes and signs it into one run per box. Names are resolved on the host; private and cloud-metadata addresses are always refused; secrets never enter the VM, which sees placeholders the host proxy swaps for real values only in one header for bound hosts; and changes come back as a patch applied file by file after the signer decides each file. Box fails closed, and each job ends as a .tkb bundle anyone can verify offline. All 13 end-to-end checks pass on a real KVM runner (the test that runs them takes 28.5 s, including image pull and boot); 15 fuzz targets on the trust boundary produced nine findings, all fixed; and the demo run's 102 signed records verify. Box does not yet run Firecracker's jailer, needs a co-located signer, and records processes inside the VM only where they cross a door.
Figures & tables
| Approach | Hardware isolation | Egress decided | Key never in sandbox | Changes decided per file | Signed, offline-verifiable | Fails closed |
| Container with a network policy | – | – | – | – | ||
| gVisor / Kata | / ✓ | – | – | – | ||
| Agent sandboxes (E2B, microsandbox, OpenShell, sandbox-runtime) | ✓ | – | – | – | ||
| Tracing and observability | – | – | – | – | – | – |
| Tracekit alone | – | – | ✓ | ✓ | ||
| Tracekit Box (v0) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Domain | Trust | What runs there |
| Host | trusted | tkbox daemon , the egress proxy, the image builder, the secrets passed in, the user’s checkout |
| Guest (micro-VM) | untrusted | the agent, everything it runs, and the guest agent tkbox-guest itself |
| Signer | trusted, separate from the agent | the Tracekit 1.4.2 signer and its signing key, on the same host as the daemon, reached only over a Unix socket |
| Goal | What stops it |
| Run code on the host | the KVM boundary and Firecracker’s small device model with its default seccomp filter (no jailer in v0); the daemon parses guest input as untrusted (bounded frames, strict JSON, path checks) |
| Reach the network directly | the VM has no network card; the only route out is the egress proxy on vsock |
| Reach internal services or cloud metadata | names resolved once on the host; loopback, private, link-local, metadata, multicast and the host’s own addresses refused; the proxy dials the address it checked, so a name cannot rebind |
| Exfiltrate to an arbitrary host | the allowlist; any other host waits for a person on the host; TLS server name must equal the requested host; encrypted ClientHellos refused; a non-TLS tunnel only to an entry that names its port |
| Steal a secret | the value never enters the VM; the proxy swaps it into one bound header for exact bound hosts, blocks the placeholder or value elsewhere on traffic it reads, and redacts the value from responses |
| Take credentials from the checkout | the workspace builder never copies credential files ( .env* , *.pem , *.key , .netrc , .npmrc , SSH keys, .aws/ , …) |
| Port | Direction | What travels |
| 1024 | host guest | agent RPC: exec , diff , files, shutdown , in framed messages ( [type u8][len u32][payload] , payload at most 1 MiB) |
| 1025 | guest host | egress: raw HTTP proxy protocol from the guest’s forwarder on 127.0.0.1:3128 to the box’s proxy |
| 1026 | guest host | boot config: box id, environment, proxy address, the box’s CA certificate, devices, limits, the host’s clock; served once, then closed |
| Preset | Hosts |
| pypi , npm | pypi.org , files.pythonhosted.org ; registry.npmjs.org , registry.yarnpkg.com |
| github | github.com , api.github.com , codeload.github.com , raw, release and LFS hosts |
| anthropic , openai , claude-code | api.anthropic.com ; api.openai.com ; api.anthropic.com and statsig.anthropic.com |
| debian , apt-ubuntu | Debian and Ubuntu package mirrors |
| go-proxy | proxy.golang.org , sum.golang.org (these fetch on the caller’s behalf, so they can relay workspace data) |
| Kind | Files |
| CI and automation | .github/workflows/** , .github/actions/** , .gitlab-ci.yml , .circleci/** , .husky/** , lefthook.yml , .pre-commit-config.yaml |
| Git behaviour | .gitignore , .gitattributes , .gitmodules |
| The next run | tkbox.yaml , tkbox.yml , .envrc , .devcontainer/** , .vscode/tasks.json , .cargo/config.toml , .yarnrc.yml |
| Credentials | .npmrc , .netrc , *.pem , *.key and the rest of the credential-file list |
| Door | Kind | Arguments or fields | When |
| Commands | decide Bash | command (the shell-quoted line, with per-exec env as a leading env K=V ), cwd , env (placeholders shown as tkbox_NAME_… ), stdin ; up-front stdin as stdin_sha256 , stdin_bytes and, if UTF-8 and 4 KiB, stdin_text | before the process starts; complete with the exit status (and streamed stdin’s digest) |
| Network | decide WebFetch | url : https://host:443 , http://host:port or tcp://host:port | per connection, before it is dialled |
| Approvals | observe tkbox.approval (trusted) | {approval_id, target, decision, approver} | synchronously, before any connection uses an approval |
| Box’s refusals | observe tkbox.egress | {host, port, decision, reason} for connections Box refused itself (metadata, private addresses, SNI mismatch, …) | at the refusal; at most 20/s per box, then a summary |
| Secrets | observe tkbox.secret.use | {name, host} | on each swap into a bound header |
| observe tkbox.secret.leak | {name, host, reason} | on each blocked placeholder or value |
| Situation | What Box does |
| No signer at daemon start | no daemon, unless --unrecorded , which warns and records nothing |
| Signer stops answering | every later decision is refused; nothing runs, nothing connects |
| Must-keep observe cannot be queued (queue of 4096) or is refused | the box is stopped and the loss is recorded as tkbox.gap |
| Approval record refused | the connection is refused, the box stopped, the loss in tkbox.gap ; if even that fails, the run is closed with the loss as its reason |
| A complete is refused or unanswered | counted in tkbox.gap with kind complete ; the box keeps running |
| Box’s own refusals above 20/s | a summary observation counts the rest |
| Deployment | Verifier says | Who could forge a record |
| Dev signer ( tracekit init --dev ), as the daemon’s user | Assurance: dev , same-user | not the agent (it is in the VM), but any process of that user |
| Signer as its own user on the daemon’s host | separate-user | root, or whoever controls the signer’s user |
| Plus a witness on another host | above dev | a rewrite after a checkpoint is cosigned would be caught by the witness |
| GitHub Action | Assurance: dev | the job itself; the trust file shows only that the bundle was not changed after the job |
| Package | Role | Code | Tests |
| internal/daemon | box manager, HTTP API, exec, approvals, apply, events, containment | 6,165 | 4,891 |
| internal/egress | proxy, allowlist and presets, address checks, per-box CA, secret swap, leak blocking, redaction | 4,590 | 4,568 |
| internal/guest | PID 1 init, agent RPC server, exec, diff, egress forwarder, boot config | 3,142 | 1,015 |
| internal/image | OCI pull and cache, layer flattening, ext4 builds, the workspace image | 2,929 | 1,867 |
| cmd/tkbox | CLI and MCP server | 2,192 | 1,073 |
| internal/tracekit | signer RPC client, run lifecycle, decision gate, export | 2,199 | 1,811 |
| Command | What it does |
| tkbox daemon | run the daemon: boxes, egress proxies, the API socket |
| tkbox run | check the settings, then create and start a box |
| tkbox exec <box> -- argv | run a command in a box (decided first) |
| tkbox ls / stop / rm | list; stop and save the patch; delete |
| tkbox diff <box> | print the changes as a git-format patch |
| tkbox apply <box> | apply a stopped box’s patch, file by file through the signer |
| Check | Door | What it does and what it requires | Time (s) |
| exec | commands | runs a command through the guest agent, decided by the signer | 0.06 |
| unread-stdin-interrupt | commands | a command with 2 MB of stdin it never reads still stops when the client sends Ctrl-C | 2.05 |
| unread-stdin-kill | commands | …and when the client is killed outright | 2.11 |
| signer-deny | commands | a command that would kill the signer ( pkill tracekitd ) is denied by the signer’s policy before it starts: exit 126, rule TK-D007 | 0.01 |
| workspace-excludes-ignored | workspace | the checkout’s .env and .git are not in /workspace ; its tracked files are | 0.01 |
| no-network-card | network | /proc/net/dev in the guest lists no interface but lo | 0.01 |
| Record | Count |
| policy.decision (Bash and WebFetch) | 46 |
| tool.result (43 ok, 3 not ok) | 46 |
| input.observed : tkbox.box | 3 |
| tkbox.egress (Box’s own refusals) | 2 |
| tkbox.secret.use | 1 |
| tkbox.approval | 1 |
| Target | Package | Property |
| FuzzAddrForms | egress | any textual form of a blocked address is refused wherever the proxy parses a host; metadata addresses are refused even under AllowPrivate ; block prefixes hold in every form |
| FuzzHostText | egress | host text accepted as an IP literal is checked as that literal; text accepted as a name is not one inet_aton reads as IPv4 |
| FuzzClientHello | egress | an admitted ClientHello names the CONNECT host and only it, in every reading; exactly one ClientHello is forwarded |
| FuzzForward | egress | the upstream gets the path and query Box checked and recorded; the guest never sees a secret value a bound host echoed |
| FuzzRedactReader | egress | a body carrying secret values in any claimed form, split at any read boundary, comes out the same length and with none of them; gzip and deflate too |
| FuzzReadFrame | proto | any byte stream decodes to bounded frames, allocates at most one bounded payload per frame, and re-encodes exactly |
| ID | Area | Finding and fix |
| F1 | patch parser | /dev/null on a --- or +++ line without its new file mode or deleted file mode line is a file named dev/null to git, not a creation or deletion; now refused |
| F2 | egress | a response status below 100 had its body relayed without redaction; now a 502 |
| F3 | egress | an opaque request target inside a TLS tunnel could carry a swapped secret to another host; now refused |
| F4 | egress | a redaction marker could rebuild a value with the bytes around it; markers can no longer take part in a match |
| F5 | commands | a program name starting with % is a job spec to bash and zsh, so the decided command line would not name what runs; now refused |
| F6 | config | a refused --secret flag could quote part of a pasted value in its error; the source is now checked first and never quoted |
| Pass | Examples of fixes |
| review-1 | a CONNECT to 443 under an entry naming 443 recorded as tcp:// ; base64 values that are JSON-escaped or percent-encoded redacted |
| re-review | gaps in apply, stdin, the observer, events and MCP limits |
| verify-3, 3b | documentation claims corrected; apply stated plainly to have no rollback |
| verify-5 | more per-exec env names that run code refused; apply writes into a global core.hooksPath refused; an approval lost after a stopping box wrote its gap record now recorded |
| final checks | a run not closed under an approval record still in flight; no part of a nested secret value shown in a refusal or event; three guarantees backed only by code given tests |
| Item | Result |
| End-to-end checks on KVM (CI run #16) | 13 / 13 pass |
| TestBox (the 13 checks), including pull and boot | 28.5 s |
| Unit tests; end-to-end test functions | 340; 2 |
| Fuzz targets; findings fixed | 15; 9 |
| Demo bundle: records; decisions; verifies | 102; 46; yes |
| Assurance of every bundle reported | dev |
| Not in v0 | Today |
| Firecracker jailer | Firecracker runs as the daemon’s user with its default seccomp filter, without the jailer’s chroot, namespaces and cgroups; the VMM interface already takes a jailer config |
| Remote signer | the signer must be on the daemon’s host (Unix socket only) |
| Private registries | public images only |
| Snapshots and restore | each box boots from its image; a stopped box cannot be resumed |
| Kubernetes RuntimeClass | run tkbox daemon on the node or runner directly |
| gVisor fallback | no /dev/kvm , no daemon |
Appendix figures & tables1 asset
Supplementary material from the paper’s appendix.
Appendix
| Limit | Default |
| vCPUs; memory; workspace disk | 2; 2 GiB; 4 GiB |
| Box lifetime; each command | 1 h; 30 min |
| Boxes per daemon; concurrent execs per box | 8; 64 |
| Open connections per box | 256 |
| Pending approvals per box; new approvals per minute | 16; 30 |
| Approval wait | 2 min |
Runtime Enforcement for AI Agents
Runtime enforcement for AI agents checks and constrains actions as they execute, helping prevent unsafe or unauthorized effects.
See all 279 papers