Book a call

[Paper] Tracekit Box: Running AI Agents in Micro-VMs Where Every Way Out Is Decided and Signed

[Paper] Tracekit Box: Running AI Agents in Micro-VMs Where Every Way Out Is Decided and Signed

Bravish Ghosh, Cygnux Labs.

Download the PDF · arXiv:2610.38458 · Code (MIT) · Licence: CC BY 4.0

Abstract

AI agents now do real jobs: they install packages, call model APIs with their operators’ keys, and change code in repositories that deploy to production. To do that they need a network, credentials and a way to hand back their work, and each of these is also a way to leak data, steal a key or change a system nobody approved. Sandboxes contain the agent but leave no evidence of what crossed their boundary; tracing records what happened but contains nothing. We present Tracekit Box, an open-source system (MIT licence, v0) that does both. Each job runs in its own Firecracker micro-VM with no network card. The only ways out are a few doors on the host, served over vsock: commands started in the box, 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, so the box makes no DNS queries; private, link-local and cloud-metadata addresses are refused whatever an allowlist says; secrets never enter the VM, which sees only placeholders that the host proxy swaps for real values in one header on requests to the secret’s bound hosts; and the agent’s changes come back as a patch that is applied to the checkout only file by file, after the signer has decided each file. Everything fails closed: without a signer there is no daemon, and a record Box must keep but cannot stops the box. Each job ends as a single .tkb bundle that anyone can verify offline with tracekit verify.

We report what the v0 implementation has been tested against, and what it has not. All 13 end-to-end checks pass on a real KVM runner in continuous integration, including a box with no network interface, refusal of the cloud metadata address, a secret swapped in for its bound host and blocked in another header, and an exported bundle that verifies; the test that runs them, including image pull and boot, takes 28.5 s. Fifteen fuzz targets cover the proxy’s address parsing, TLS ClientHello checks, secret redaction, the guest protocol and the patch parser, and produced nine findings, all fixed. The demo run in the repository holds 102 signed records (46 decisions) and verifies. Box does not yet run Firecracker’s jailer, supports one host with a co-located signer, and records the commands an agent starts inside the VM only where they cross a door.

Figure 1

Figure 1: The architecture of Tracekit Box. Each agent job runs in its own Firecracker micro-VM with no network card. The only ways out are vsock ports served by the daemon on the host: commands, the network (and through it secrets and model calls), and the workspace patch. At each of these doors the daemon asks a Tracekit signer, which the VM cannot reach, to decide and sign what passes. Real keys stay on the host and are added on the way out, only for the hosts they are bound to. When the job ends, the box’s record exports as one .tkb bundle that anyone can verify offline.

1 Introduction

Coding agents such as Claude Code and Codex, and agents built on frameworks such as the OpenAI Agents SDK or LangGraph, are moving from suggesting changes to making them. A typical job looks small: fix a failing test, upgrade a dependency, open a pull request. To do it the agent needs three things that are also the three ways it can do harm. It needs the network, to install packages and call its model, and the network is how data leaves. It needs credentials, a model API key and often a GitHub token, and a credential the agent can read is a credential the agent can send anywhere. And it needs a way to hand back its work, which is a way to change files nobody reviewed, including the CI workflows that run on the next push.

The text an agent reads (a README, an issue, a web page, a tool result) is often written by someone else, so an agent can be steered into misusing all three by indirect prompt injection [3, 6, 13]. It can also misuse them without any attacker: in September 2026 OpenAI reported that a research agent in a training sandbox reached an outside chatbot by tunnelling through DNS, a path its network filter did not cover [11].

Two families of tools address this today, and each covers half of it.

  • Sandboxes contain the agent: containers, gVisor [12], micro-VMs such as Firecracker [1], and agent-specific sandboxes [2, 4, 9, 10]. They decide what may leave, but they leave little evidence of what did, and the evidence they keep is a log on the same machine, written by the same operator.
  • Recording tools capture what an agent did. Tracekit [5] goes furthest: every tool call is decided and signed by a signer the agent cannot reach, and a run exports as an offline-verifiable bundle. But a signer can sign only what reaches it. An agent in an ordinary environment can open a socket, read a key from its environment or write a file without passing a hook.

Tracekit Box joins the two. It puts the agent in a room whose only exits are doors the host controls, and puts a Tracekit signer at every door. The design follows from one rule: nothing leaves the VM except through a door, and nothing passes a door without a signed decision.

The claim this paper defends

For a job run in Tracekit Box with a signer the agent cannot reach: every command started in the box from the host, every outbound connection and every file written to the checkout by tkbox apply was decided by the signer before it happened, and is in a signed record that anyone can verify offline; a bound secret’s value never enters the VM and is sent only in its bound header to its bound hosts. Box does not record what the agent does inside the VM without crossing a door, and does not judge what an allowed host is sent. A verified bundle proves the record is intact, not that the job was correct or benign.

Contributions.

  1. A door model for agent sandboxes (sections 3 and 4): a micro-VM with no network card whose only exits are vsock ports served by the host, with each exit (commands, network, secrets, model calls, the workspace patch, stop) mediated and decided.

  2. Egress for agents without DNS (section 4.4): names resolved once on the host and dialled by checked address, refusal of private, link-local, metadata and the host’s own addresses in every form, SNI bound to the requested host, and a person in the loop for hosts off the allowlist.

  3. Secrets the agent never holds (section 4.5): placeholders inside the VM, swapped for the real value in one header for exact bound hosts, with leak blocking and response redaction on every connection the proxy reads.

  4. Changes that come back as decided writes (section 4.7): a strict patch pipeline in which git apply never runs on the checkout, git must read the patch exactly as Box does, sensitive files need explicit consent, and every file is decided before anything is written.

  5. Signed, fail-closed records (section 5): one Tracekit run per box, with a mapping from each door to signed decisions and observations, and no silent loss of a record Box must keep.

  6. An open implementation and its tests (sections 6 and 7): about 25,600 lines of Go with 340 unit tests, 15 fuzz targets, an end-to-end suite on real KVM (two test functions, 13 checks), a GitHub Action, an MCP server and a Python SDK.

Relation to Tracekit. Tracekit Box is a separate project. It uses an unmodified Tracekit 1.4.2 signer through its public RPC and Tracekit’s evidence format, and adds what Tracekit alone cannot provide: an environment in which the signer’s view is complete for the doors Box controls, because the agent has no other way out. This paper describes Box; Tracekit’s signer, evidence format and verifier are described in [5].

Roadmap. Section 2 compares Box with sandboxes and recorders. Section 3 gives the threat model. Section 4 describes the box and its doors, and section 5 how they are recorded. Section 6 covers the implementation and its interfaces, section 7 the evaluation, and section 8 the limits and future work.

2 Background and related work

Isolation. Containers share the host kernel, so a kernel bug in one container is a bug in all of them. gVisor intercepts system calls with a user-space kernel, at a measurable cost [12]. Kata Containers run each container in a lightweight VM [7]. Firecracker is a minimal virtual machine monitor built for serverless workloads: a small device model, a few emulated devices, and boot times in the low hundreds of milliseconds [1]. Box uses Firecracker because a hardware-virtualisation boundary with a small device model is the strongest isolation that still starts fast enough to give every job its own VM, and because Firecracker’s vsock device lets the host offer the guest exactly the channels it chooses and nothing else.

Agent sandboxes. Several systems now run agents in isolated environments. E2B offers cloud sandboxes for agents built on Firecracker [4]. microsandbox runs self-hosted micro-VMs for untrusted code [9]. Anthropic’s sandbox-runtime confines a local agent with operating-system sandboxing and filters its network through a proxy by domain [2], and NVIDIA OpenShell runs agents in sandboxes whose outbound requests pass a policy [10]. These systems decide what an agent may reach. As far as we know none of them, as shipped, produces a record of those decisions that a third party can verify, keeps the agent’s credentials out of the sandbox while still letting it use them, or returns the agent’s changes through a decided, per-file write path.

Egress control. Allowlisting by domain is the common way to limit where an agent may connect. Its known weak points are the ones an agent can find: DNS itself as a channel [11], names that resolve to private or metadata addresses (server-side request forgery), DNS rebinding between the check and the connect, and domain fronting, where the TLS server name and the HTTP host differ. Box closes each of these at the door (section 4.4) rather than in the guest, because the guest is the attacker.

Credential brokering. Keeping a secret out of the process that uses it is an old idea: credential helpers, cloud workload identity, and proxies that add authentication headers on the way out. Box applies it to agents: the VM holds only a placeholder, and the host proxy adds the real value for exact hosts in one header, blocks the placeholder or the value anywhere else on traffic it reads, and redacts the value from responses (section 4.5).

Tamper-evident records of agent actions. Tracekit [5] records every tool call through a signer the agent cannot reach, in a per-run hash chain inside an RFC 6962 Merkle tree [8] whose checkpoints witnesses can cosign, and exports runs as offline-verifiable bundles. Its limit is completeness: an action that never reaches the signer leaves no record. Box supplies the missing half for the actions it mediates: in a VM with no other exit, a connection, a secret use or a file write that leaves the box must pass a door, and every door asks the signer.

Table 1: What each approach provides for an agent job. ✓ provided, ∘ partial, – not provided. Rows describe the approach as typically deployed.

ApproachHardware isolationEgress decidedKey never in sandboxChanges decided per fileSigned, offline-verifiableFails closed
Container with a network policy–∘–––∘
gVisor / Kata∘ / ✓∘–––∘
Agent sandboxes (E2B, microsandbox, OpenShell, sandbox-runtime)∘✓–––∘
Tracing and observability––––––
Tracekit alone–∘–∘✓✓
Tracekit Box (v0)✓✓✓✓✓✓

Table 1 summarises the comparison. The point of Box is not any one column: each has been done before. It is that the columns hold together, so that the record is complete for the doors, and the doors are the only ways out.

3 Threat model

3.1 Trust domains

Box has three trust domains (table 2). The attacker controls everything inside the VM: the agent, everything it runs, the guest kernel’s view of the world, and every byte the guest agent sends. We assume the agent is hostile, either prompt-injected or following instructions from a file it read. The attacker does not control the host, the signer, or the person who approves connections on the host.

Table 2: Trust domains.

DomainTrustWhat runs there
Hosttrustedtkbox daemon, the egress proxy, the image builder, the secrets passed in, the user’s checkout
Guest (micro-VM)untrustedthe agent, everything it runs, and the guest agent tkbox-guest itself
Signertrusted, separate from the agentthe Tracekit 1.4.2 signer and its signing key, on the same host as the daemon, reached only over a Unix socket

3.2 What the attacker wants, and what stops it

Table 3 lists the attacker’s goals and the mechanism that stands against each. The details are in section 4.

Table 3: Attacker goals and what stands in the way (abridged from the project’s security policy).

GoalWhat stops it
Run code on the hostthe 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 directlythe VM has no network card; the only route out is the egress proxy on vsock
Reach internal services or cloud metadatanames 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 hostthe 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 secretthe 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 checkoutthe workspace builder never copies credential files (.env*, *.pem, *.key, .netrc, .npmrc, SSH keys, .aws/, …)
Start a command without a recordcommands started from the host run only through the guest agent’s exec, decided by the signer before they start
Change the repository behind your backchanges leave only as a patch, applied by tkbox apply through a strict pipeline that decides every file before writing
Rewrite or silence the recordthe record is in the signer’s trust domain; Box fails closed; a must-keep record that cannot be recorded stops the box and is counted as tkbox.gap
Reach the daemon or signer socketsboth are Unix sockets on the host; the VM has no path to them

3.3 What Box guarantees

With a signer the agent cannot reach, and within the limits of section 8.1:

G1. Every command started in the box through tkbox exec (or the SDK, the GitHub Action or MCP) was decided by the signer before it started, and its outcome is in the signed record, or counted in the run’s tkbox.gap record if it could not be recorded.

G2. Every outbound connection was decided before it was dialled: allowed by the allowlist, approved by a person on the host, or refused. Each is in the record, except Box’s own refusals beyond 20 a second per box, which are counted in a summary record.

G3. A bound secret’s value is sent only to its bound hosts, and only in its bound header. Each use is recorded by name and host, never by value.

G4. tkbox apply writes nothing to the checkout that the signer did not allow, file by file.

G5. The bundle is the signer’s record of the run; tracekit verify detects any change to it.

What Box does not guarantee

  • Processes inside the VM. The processes a command starts itself are not decided or recorded one by one; they show up where they cross a door.
  • What an allowed host is sent. Box decides who the agent may talk to, not what it says. A host that fetches on its caller’s behalf (such as the Go module proxy) can relay workspace data further.
  • Leaks on uninspected connections. On HTTPS hosts without a bound secret the proxy sees only the host, port and server name; only a placeholder could leave there, never a value.
  • Assurance. A same-user dev signer gives Assurance: dev: the agent in the VM cannot reach its key, but another process of the same user could. Stronger assurance needs a signer under its own user and a witness on another host.
  • The host. Root and the operator are trusted; v0 runs Firecracker without its jailer.

4 Design

4.1 A job’s life

Figure 2 shows a job from start to verification. tkbox run builds the box’s disks, registers a Tracekit run and boots the VM; tkbox exec runs commands in it; every connection, secret use and model call passes the proxy; tkbox stop saves the workspace patch; tkbox apply writes the decided changes to the checkout; and tkbox export closes the run and writes the bundle and its trust file.

Figure 2

Figure 2: The life of a job and what each step records in the box’s Tracekit run (bottom row).

4.2 The box

No network card. The VM is a Firecracker micro-VM [1] with a pinned guest kernel (6.1.155) and VMM (v1.17.0), both downloaded once and checked by SHA-256. It has no network interface: its only device for talking to the host is vsock, which Firecracker exposes on the host as a Unix socket. The daemon serves three ports on it (table 4), and nothing else in the guest can reach the host or the network. Because there is no interface, there is no DNS and no UDP from the box at all: the guest cannot leak data in name lookups, because it cannot make any.

Table 4: The vsock ports: the only channels between guest and host.

PortDirectionWhat travels
1024host → guestagent RPC: exec, diff, files, shutdown, in framed messages ([type u8][len u32][payload], payload at most 1 MiB)
1025guest → hostegress: raw HTTP proxy protocol from the guest’s forwarder on 127.0.0.1:3128 to the box’s proxy
1026guest → hostboot config: box id, environment, proxy address, the box’s CA certificate, devices, limits, the host’s clock; served once, then closed

Disks. A box has three disks (fig. 3). The root filesystem is built from a public OCI image by Box’s own registry client: layers are flattened with whiteouts, the guest agent is added, and the result is written as an ext4 image and cached by manifest and guest-agent digest. It is mounted read-only with a tmpfs overlay, so writes outside the workspace live in RAM and vanish with the box. The workspace is an overlay of a read-only base disk, built from the checkout, and an empty writable upper disk. The base contains tracked and untracked-but-not-ignored files of a git work tree, never .git, and never credential files, ignored or not. Because the guest’s diff walks only the upper layer, the patch is exact and cheap, and the image needs no git.

Figure 3

Figure 3: The box’s disks. The patch is computed from the workspace’s upper layer, so it contains exactly what the job changed.

The guest is untrusted, including its agent. tkbox-guest runs as PID 1 and serves exec, diff and the egress forwarder. The host never trusts what it says about itself: frames are bounded, JSON is parsed strictly, paths are checked, and only what passes a door is recorded as fact.

4.3 Commands

A command started in the box from the host (tkbox exec, the Python SDK, the GitHub Action or the MCP server) runs only through the guest agent’s exec, and only after the signer allows it. Box sends the signer a Bash decision whose arguments are the shell-quoted command line, the working directory, the exec’s environment and its stdin when known up front (section 5). Three details make that record faithful:

  • The program that runs is the program the signer reads. A first word that contains = or is a shell reserved word is quoted, so a shell parser sees the program that runs; a first word starting with % (a job spec to bash) is refused.
  • The environment cannot change what runs unseen. A per-exec environment appears in the decided command as a leading env K=V …, and names that change what a program loads or runs (every LD_*, GIT_*, PYTHON*, NODE_*, BASH_*, PATH, HOME, PS4, build-tool wrappers and more, bar a few harmless ones) are refused per exec.
  • Stdin is decided when it can be. Stdin known up front (at most 1 MiB) is decided with the command as its SHA-256 and size, plus its text when it is UTF-8 of at most 4 KiB; exactly that data becomes the process’s stdin. Streamed stdin is known only afterwards, so its digest goes in the completion.

The processes a command starts itself, such as the shell commands an agent runs, are not decided one by one. Containing them is the VM’s job; what they do is recorded where it crosses another door.

4.4 Network

The guest’s forwarder passes HTTP proxy traffic (CONNECT tunnels and absolute-form plain HTTP) over vsock to a per-box proxy on the host. Figure 4 shows how one connection is decided.

Figure 4

Figure 4: Deciding one outbound connection, in the order the proxy checks. A name on the allowlist (or an IP literal) is resolved once on the host and checked before the signer is asked; a name off the list is not resolved until it is allowed, because the lookup itself would carry whatever the guest encoded in the name to that domain’s DNS servers. The proxy dials the address it checked, so a name cannot rebind between the check and the connect, and it checks the TLS ClientHello before it forwards any byte of the tunnel. An allowlisted name skips the person, never the signer.

Addresses. The proxy resolves a name once, on the host, and refuses loopback, RFC 1918, CGNAT, unique-local, link-local, multicast, documentation and other special ranges, the cloud metadata addresses (169.254.169.254, fd00:ec2::254, 169.254.170.2, 100.100.100.200, 168.63.129.16 and others), and the host’s own interface addresses, which it re-reads at most every 5 s. IPv4-mapped and NAT64 addresses are unwrapped first. It then dials the address it checked. A name on the allowlist, or an IP literal, is resolved and checked before the signer is asked, so the metadata address is refused before any decision; a name off the allowlist is resolved only after the signer and a person have allowed it. On cloud VMs whose public IP is NAT’d and on no interface, the operator passes that address with --egress-block.

Allowlist and approvals. An allow entry host permits HTTPS on 443 and plain HTTP on 80; host:port permits that port only; *.host permits subdomains. Presets name the hosts common jobs need (table 5). Every connection is a WebFetch decision, on the list or off it. A host off the list is not refused outright: after the signer allows it, the connection waits up to two minutes for a person on the host to run tkbox approve, which nothing in the VM can do. At most 16 approvals may be pending and 30 opened per minute per box. With inspect: all, hosts off the list are refused at once.

Table 5: Allow presets.

PresetHosts
pypi, npmpypi.org, files.pythonhosted.org; registry.npmjs.org, registry.yarnpkg.com
githubgithub.com, api.github.com, codeload.github.com, raw, release and LFS hosts
anthropic, openai, claude-codeapi.anthropic.com; api.openai.com; api.anthropic.com and statsig.anthropic.com
debian, apt-ubuntuDebian and Ubuntu package mirrors
go-proxyproxy.golang.org, sum.golang.org (these fetch on the caller’s behalf, so they can relay workspace data)

TLS. Every HTTPS tunnel must start with a ClientHello whose single server name equals the CONNECT host, checked again after a HelloRetryRequest; encrypted ClientHellos are refused because Box must see the name. This stops fronting by SNI. A tunnel that is not TLS is allowed only to an entry that names its port, and is recorded as tcp://host:port, since the decision comes before the first byte. CONNECT to port 80 is refused, so plain HTTP always goes through the proxy in a form it reads.

4.5 Secrets

A secret is given on the host (--secret GH=env:GH_TOKEN@api.github.com, or in tkbox.yaml), bound to exact host names (no wildcards) and to one header (Authorization by default). The VM sees only a placeholder, tkbox_GH_ followed by 32 hex digits. Figure 5 shows what happens to it.

Figure 5

Figure 5: A secret’s two paths. To its bound host, in its bound header, the proxy swaps the placeholder for the real value and records the use. Anywhere else on traffic the proxy reads, the placeholder (or the value) is blocked and the attempt is recorded.

On every connection the proxy reads (bound hosts, plain HTTP, and every allowed host with inspect: all) it blocks the placeholder anywhere but the bound header, and blocks the real value where it is not being swapped in, in its raw, percent-encoded, JSON-escaped, hex and base64 forms in any case. It redacts those forms from response headers, bodies and trailers, keeping their length so Content-Length still holds; requests to bound hosts ask for unencoded responses so they can be checked, and upgrades (WebSocket) and partial responses are refused on them. To read a bound host’s TLS traffic the proxy uses a per-box ECDSA P-256 CA that is name-constrained to exactly those hosts, and verifies the upstream certificate against the host’s roots. Before the box starts, Box also refuses any workspace file whose content, path or symlink target holds a secret’s value, and any environment value or command line that does.

4.6 Model calls

A model API is egress to a model host named by a preset or allow entry (--allow anthropic), with the API key bound to it as a secret. The agent inside the box uses its normal SDK with the placeholder as its key. The connection gets its WebFetch decision like any other, and each request the proxy reads to a model host is also recorded as tkbox.model.call with host, path, status and size. A secret’s host alone does not make a model host.

4.7 Changes

Changes leave the box only as a git-format patch of /workspace, computed in the guest from the overlay’s upper layer and saved on the host when the box stops. tkbox apply writes it to the checkout, on a stopped box only, through the pipeline in fig. 6. git apply never runs on the checkout.

Figure 6

Figure 6: The tkbox apply pipeline. Nothing is written unless the signer allows every file, and what is written is what was decided.

Each step closes a specific gap between “the patch the signer saw” and “the bytes on disk”. The strict parser and the cross-check against git’s own reading (step 2, with an empty git directory and work tree) stop a patch that git would read differently from Box. The scratch build runs git with an empty HOME, no global or system config and no attributes file, so no filter, hook or LFS program of the user’s runs on the agent’s patch. Sensitive files, those that steer the next run or host automation, need --allow-sensitive (table 6). The re-check in step 6 means a file changed on disk during the decisions is caught before anything is written, and Box writes each file itself, atomically, never through a symlinked parent.

Table 6: Files a patch may change only with --allow-sensitive.

KindFiles
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 runtkbox.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

4.8 Stop and containment

A box stops on its timeout (default one hour), on tkbox stop, when the signer holds or quarantines its run, or when Box fails closed. Egress closes the moment any stop begins: the proxy, its open connections and the egress listener close, and every later egress decision is refused, so no secret is swapped in while the box goes down. The daemon polls the signer’s containment state for each running box (every 2 s in its first minute, then every 15 s, and at once after a decision that names an incident); a hold pauses the VM and a quarantine kills it.

5 Recording the doors

Box does not have its own log format, signer or verifier. It uses an unmodified Tracekit 1.4.2 signer through the signer’s public RPC (newline-delimited JSON over its Unix socket, with the caller’s identity taken from the socket’s peer credentials), and Tracekit’s bundle format and verifier [5]. This section describes what Box sends to the signer, when, and what happens when the signer cannot take it.

5.1 One run per box

Each box is one Tracekit run (fig. 7). The daemon calls register_run when the box is created and records the box’s settings (image and its digest, allowlist, limits) as a trusted tkbox.box observation and the operator’s task as a trusted tkbox.task observation, with every secret value and placeholder scrubbed. tkbox stop keeps the run open, so that the Write decisions of a later tkbox apply go into the same run as the commands and connections that produced the patch. tkbox export and tkbox rm close the run, as do daemon shutdown, quarantine and a failed box. Export and removal never close a run under a decision still in flight: they wait briefly for it, then refuse.

The run token is held only in the daemon’s memory. After a daemon crash the run stays open in the signer, and that box can be neither applied nor exported: export refuses at once rather than produce a bundle the daemon cannot vouch for.

Figure 7

Figure 7: A box’s life and its Tracekit run. The run stays open after tkbox stop so that tkbox apply records its per-file Write decisions in the same run; export, removal, quarantine, a failure or daemon shutdown close it.

5.2 From doors to records

Tracekit records two kinds of entry: decisions, where the signer evaluates a tool call against its policy before the call happens and later receives its completion, and observations, which the signer signs but does not decide. Table 7 gives the mapping for every door. Box maps its doors onto Tracekit’s existing tool names (Bash, WebFetch, Write) so that the signer’s standard policy packs apply to them without change; for example a Bash command that pipes a download into a shell, or a Write to a CI workflow file, meets the same rules it would meet from any other Tracekit adapter.

Table 7: What each door sends to the signer. Decisions are made before the action and completed after it; observations are signed but not decided. No record ever holds a secret’s value.

DoorKindArguments or fieldsWhen
Commandsdecide Bashcommand (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_textbefore the process starts; complete with the exit status (and streamed stdin’s digest)
Networkdecide WebFetchurl: https://host:443, http://host:port or tcp://host:portper connection, before it is dialled
Approvalsobserve tkbox.approval (trusted){approval_id, target, decision, approver}synchronously, before any connection uses an approval
Box’s refusalsobserve 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
Secretsobserve 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
Model callsobserve tkbox.model.call{host, path, status, bytes}per request to a model host the proxy reads
Changesdecide Writefile_path, sha256, mode, symlink, deleted, patch_sha256, and sensitive: true where it appliesfor every file, before anything is written; completions carry written_paths
Lifecycleobserve tkbox.box (trusted), tkbox.task (trusted)box id, image and digest, allowlist, limits (scrubbed); the operator’s task; agent choices are recorded separately as untrustedsynchronously, at each transition
Containmentobserve tkbox.contained{state, incident_id}when a hold or quarantine pauses or kills the box
Lost recordsobserve tkbox.gap{kind, dropped, reason}when a must-keep record could not be recorded
Endclose_runthe reasonexport, rm, shutdown, quarantine, failure

Commands. The command line the signer sees is the one that runs. Box shell-quotes the argument vector itself, and quotes a first word that contains = or is a shell reserved word, so the signer’s command parser sees the program that actually runs rather than an assignment or a keyword. Stdin that is known before the command starts (up to 1 MiB) is decided together with the command, and exactly that data becomes the process’s stdin.

Connections. The url is fixed before the first byte of the connection, which is why a tunnel to a non-TLS port is recorded as tcp://host:port: Box cannot know what protocol will run over it, and does not claim to.

Approvals. Tracekit 1.4.2 opens an approval only when policy returns ask. A host off Box’s allowlist is a Box rule, not a signer policy, so Box holds the connection itself and records the person’s answer as tkbox.approval. An approval that allows a connection is recorded before the connection uses it; if the signer refuses that record, the connection is refused.

What the bundle holds. In the exported bundle an observation’s content appears as a salted HMAC with its size, as Tracekit records observations; the bundle proves that each observation was made, where it falls in the run, and that it was not changed. Each observation is labelled trusted (Box’s own statements: settings, task, approvals) or untrusted (facts derived from what the guest sent, such as a connection attempt or a secret use), so a reviewer knows which entries rest on the agent’s input.

5.3 Failing closed

A record that is lost silently is worse than one that was never promised, because a reader of the bundle cannot tell the difference between “did not happen” and “was not recorded”. Box’s rules (table 8) are designed so that every loss is either impossible or visible.

Table 8: Fail-closed rules.

SituationWhat Box does
No signer at daemon startno daemon, unless --unrecorded, which warns and records nothing
Signer stops answeringevery later decision is refused; nothing runs, nothing connects
Must-keep observe cannot be queued (queue of 4096) or is refusedthe box is stopped and the loss is recorded as tkbox.gap
Approval record refusedthe 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 unansweredcounted in tkbox.gap with kind complete; the box keeps running
Box’s own refusals above 20/sa summary observation counts the rest
Daemon crashthe run stays open; the box cannot be applied or exported
Unrecorded box’s patch on a recorded daemontkbox apply refuses it

“Must-keep” observations are the ones a reviewer would need to judge the run: secret uses and leaks, model calls, Box’s rejections and expiries of approvals, and containment. The only records Box rate-limits are its own refusals of connections (for example a loop hammering a refused host), and even those leave a count.

5.4 Export and verification

tkbox export closes the run, then runs tracekit signer trust and tracekit export --v2, which write the bundle run.tkb and its trust file run.trust.json. Anyone with both files can run tracekit verify offline: it checks the manifest, the signed checkpoint, every record signature, the run’s hash chain and the inclusion of the run’s first and last records in the checkpointed Merkle tree [8]. Figure 8 shows the verifier’s report for the demo run in the repository.

Figure 8

Figure 8: Stopping, exporting and verifying the demo run, cut from the session’s screen recording (lines over 104 columns wrapped). tracekit verify 1.4.2 checks the bundle offline: 102 signed records, a checkpoint at tree size 5,978, Integrity: VERIFIED, Assurance: dev. The coverage matrix is Tracekit’s generic view: it does not know that in a box, the network and file writes have no other way out.

5.5 Assurance

What a verified bundle proves depends on who could reach the signer’s key (table 9). The integrity check proves that no record was changed after it was signed, by anyone, in every row. What changes is who could have signed something false in the first place.

Table 9: Assurance by deployment.

DeploymentVerifier saysWho could forge a record
Dev signer (tracekit init --dev), as the daemon’s userAssurance: dev, same-usernot the agent (it is in the VM), but any process of that user
Signer as its own user on the daemon’s hostseparate-userroot, or whoever controls the signer’s user
Plus a witness on another hostabove deva rewrite after a checkpoint is cosigned would be caught by the witness
GitHub ActionAssurance: devthe job itself; the trust file shows only that the bundle was not changed after the job

All the bundles reported in this paper come from dev signers. They show that the doors were recorded and that the records were not changed afterwards; they do not show whose machine produced them.

6 Implementation

Tracekit Box v0 is written in Go 1.26 and released under the MIT licence. It has two third-party Go dependencies (golang.org/x/sys and gopkg.in/yaml.v3); the egress proxy, the TLS ClientHello parser, the patch parser and the OCI image puller are written against the standard library. On the host it runs mkfs.ext4 and debugfs to build disk images and git to read the checkout and cross-check patches. The repository holds about 25,600 lines of non-test Go and about 19,000 lines of Go tests (342 Test functions, of which 340 are unit tests and 2 are the end-to-end tests of section 7.1, and 15 Fuzz functions), plus a Python SDK (about 1,800 lines with its tests) and a GitHub Action.

6.1 Structure

Table 10 lists the packages and fig. 9 their sizes. Most of the code, and most of the tests, sit at the two doors an agent is most likely to attack: the daemon (which owns commands, approvals and tkbox apply) and the egress proxy (which owns connections, secrets and model calls). The egress package has about as many lines of tests as of code.

Table 10: Packages. Lines of non-test and test Go (wc -l) at commit 473723b.

PackageRoleCodeTests
internal/daemonbox manager, HTTP API, exec, approvals, apply, events, containment6,1654,891
internal/egressproxy, allowlist and presets, address checks, per-box CA, secret swap, leak blocking, redaction4,5904,568
internal/guestPID 1 init, agent RPC server, exec, diff, egress forwarder, boot config3,1421,015
internal/imageOCI pull and cache, layer flattening, ext4 builds, the workspace image2,9291,867
cmd/tkboxCLI and MCP server2,1921,073
internal/tracekitsigner RPC client, run lifecycle, decision gate, export2,1991,811
internal/vmmFirecracker client, VM lifecycle, vsock1,4011,245
internal/agentrpchost-side client of the guest agent704909
internal/configtkbox.yaml, flags, limits641379
cmd/tkbox-runsthe Verified Runs table566448
internal/proto, apiframe format, guest operations, daemon API types709221
internal/assetspinned Firecracker and kernel downloads315221
e2eend-to-end suite on KVM–328

Figure 9

Figure 9: Code and test lines by package. The proxy (egress) is tested about line for line.

Testing without KVM. Every boundary has an interface and a fake: vmm.Launcher, agentrpc.Guest, image.Builder, egress.Decider and tracekit.Client/Run. Packages therefore test without a VM, a registry or a signer, and the end-to-end suite (section 7.1) runs the real pieces together on a host with KVM.

Pinned assets. On its first start the daemon downloads Firecracker v1.17.0 and guest kernel 6.1.155, checks each against a SHA-256 compiled into the binary, and caches them; a mismatch installs nothing. An operator can pass local files instead.

The guest. tkbox-guest runs as PID 1. It mounts the root file system read-only with a tmpfs overlay, mounts the workspace overlay, reads its boot configuration once from vsock port 1026, starts the egress forwarder on 127.0.0.1:3128, exports the proxy and CA variables to every command, and serves the agent RPC on port 1024. It is the only code Box puts inside the VM, and the daemon treats everything it says as untrusted: frames are bounded at 1 MiB, JSON is parsed strictly, and paths are checked on the host.

State. The daemon keeps images by digest and one directory per box (the Firecracker and vsock sockets, the console log, an events file capped at 256 MiB, the two workspace disks, and the saved patch and its file summary). No file holds a secret value or the run token. The API is HTTP over a Unix socket that only the daemon’s user and root can open.

6.2 Interfaces

Every interface goes through the same daemon API and the same doors; none has a path around a decision. Table 11 lists the CLI.

Table 11: The tkbox command line.

CommandWhat it does
tkbox daemonrun the daemon: boxes, egress proxies, the API socket
tkbox runcheck the settings, then create and start a box
tkbox exec <box> -- argvrun a command in a box (decided first)
tkbox ls / stop / rmlist; 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
tkbox export <box> -o x.tkbclose the run; write the bundle and trust file
tkbox events <box>print the box’s events
tkbox image build <ref>pull an image and build its root file system
tkbox approvals / approve / rejectlist and answer pending approvals
tkbox mcpserve boxes as MCP tools on stdio

MCP server. tkbox mcp lets an agent orchestrator on the host use boxes as tools (tkbox_run, _exec, _diff, _stop, _ls). The operator’s --config sets the image, allowlist, secrets, environment and limits, and a call can only narrow them: a smaller allowlist drawn from the config’s, an image the operator listed, a workspace under a root the operator named, resources at most the config’s. A call that tries to add environment, set a task or name a box is refused with the field it would have widened. An MCP session reaches only the boxes it created, stops them when it ends, and has no tools for apply or export: a person does those. The narrowing holds only if the orchestrator cannot act as the daemon’s user by another route, so the documentation tells operators to run it as a different user.

GitHub Action. The action runs Claude Code, Codex or a command in a box on a GitHub-hosted runner, applies the changes when the agent exits 0 and the signer allows every file, and uploads the bundle, trust file and patch. It never passes --allow-sensitive. Because code the agent wrote runs wherever it is run, the example workflow splits the job three ways (fig. 10).

Figure 10

Figure 10: The three-job shape of the example workflow: only the box runs agent code while a model key is present, the job that runs the agent’s code holds no secrets, and the job that holds the write token runs no repository code.

Python SDK. A standard-library client (Python 3.9 and later) for the daemon’s API: create_box, exec, diff, apply, export, events, approvals, approve, reject. A Secret carries its value and bound hosts to the daemon, which keeps the value out of the VM; its repr never prints the value. A daemon the client cannot reach raises DaemonUnreachable; a signer the daemon cannot reach raises Unavailable, because the daemon fails closed.

Verified Runs. The repository keeps a folder of real agent runs, each a bundle, its trust file and the contributor’s run.yaml. make runs verifies every bundle with tracekit verify and rewrites the table from the verified records (commands, connections, held, refused); a run that does not verify is not listed, and no number in the table is typed by hand.

7 Evaluation

We evaluate whether the v0 implementation does what section 3.3 says, on real hardware, and how it has been tested. We ask four questions:

Q1. Does each door behave as designed on a real KVM host, end to end? (section 7.1)

Q2. What does a signed record of a real box look like, and does it verify? (section 7.2)

Q3. How well are the parsers at the trust boundary tested against hostile input? (section 7.3)

Q4. What did review find, and what is still open? (section 7.4)

What this evaluation does not measure

  • Performance. We report the end-to-end suite’s wall-clock times, which include image pull and boot, but we have not benchmarked boot latency, exec overhead, proxy throughput or the cost of a signer decision per connection.
  • Adversarial agents. No study has yet put a capable agent in a box with instructions to get out. The end-to-end checks are scripted attacks on each door, not an agent searching for one.
  • Deployments above dev assurance. Every bundle here comes from a dev signer running as the daemon’s user (table 9).
  • Scale. One host, one daemon, a handful of boxes.

7.1 End to end on KVM

Setup. The e2e package runs the real daemon, real Firecracker, the real guest, a real Tracekit 1.4.2 dev signer and real registries, on a GitHub-hosted Ubuntu runner with /dev/kvm. It is the e2e job of the repository’s ci workflow and runs on every push. We report CI run #16 (commit 473723b, 26 October 2026): the job took 1 min 6 s, of which make e2e took 33 s.

Results. Table 12 lists the 13 checks of TestBox; all passed, in 28.47 s for the test function. TestFailClosed, which checks that the daemon refuses to start without a signer, and does so before downloading anything, also passed. Figure 11 shows where the time goes: three checks wait on purpose (a client giving up on a held host, a command that must be seen to start before it is interrupted or its client killed), one installs a package from PyPI, and one stops a box, applies its patch, exports it and verifies the bundle. The checks of the doors themselves take tens of milliseconds.

Table 12: The end-to-end checks of TestBox in CI run #16. All passed. Times are as go test -v printed them.

CheckDoorWhat it does and what it requiresTime (s)
execcommandsruns a command through the guest agent, decided by the signer0.06
unread-stdin-interruptcommandsa command with 2 MB of stdin it never reads still stops when the client sends Ctrl-C2.05
unread-stdin-killcommands… and when the client is killed outright2.11
signer-denycommandsa command that would kill the signer (pkill tracekitd) is denied by the signer’s policy before it starts: exit 126, rule TK-D0070.01
workspace-excludes-ignoredworkspacethe checkout’s .env and .git are not in /workspace; its tracked files are0.01
no-network-cardnetwork/proc/net/dev in the guest lists no interface but lo0.01
egress-unlisted-blockednetworka fetch of example.com, off the allowlist, is held for tkbox approve; unanswered, it fails5.39
egress-preset-allowednetworkpip install requests succeeds through the pypi preset2.25
egress-metadata-refusednetworka fetch of 169.254.169.254 fails: the proxy answers 403, “a cloud metadata service address”0.08
secret-swapsecretsa test server on the host, the secret’s bound host, receives the real value in Authorization and answers auth-ok; inside the VM $TK is only tkbox_TK_…0.10
secret-leak-blockedsecretsthe same placeholder in an X-Smuggle header is blocked with 4030.09
diff-and-applychangesa new file and an edit made in the box both appear in tkbox diff0.02
stop-export-verifychanges, recordtkbox stop; tkbox apply writes the new file to the checkout; tkbox export; tracekit verify prints VERIFIED (64 records, tree size 65, 27 tool calls, Assurance: dev, isolation: same-user)10.54
TestBox total (including image pull and boot)28.47

Figure 11

Figure 11: Duration of each end-to-end check in CI run #16. The long bars are deliberate waits (a client giving up on a held host; commands that must be seen running before they are interrupted), a real package install, and a full stop–apply–export–verify.

Reading the results against the guarantees. exec, signer-deny and the stdin checks exercise G1; the three egress-* checks and no-network-card exercise G2; secret-swap and secret-leak-blocked exercise G3; the apply step of stop-export-verify exercises G4, and its export and verify exercise G5; TestFailClosed exercises the first fail-closed rule of table 8. Each check exercises one representative case of its guarantee, not every case: the many forms of a private address, of an encoded secret or of a hostile patch are covered by unit and fuzz tests (section 7.3), not end to end.

CI history. The ci workflow (unit, end-to-end and action jobs) had run 16 times at the time of writing; four runs (#1, #2, #5 and #15) failed early, while the workflow and the end-to-end setup were being brought up, and the rest passed. A separate fuzz workflow runs every fuzz target nightly.

7.2 A real box and its record

The repository’s Verified Runs folder holds the run behind its README demo; figs. 8 and 12 show the terminal session that produced it, cut from its screen recording. An operator ran python:3.12-slim with --allow pypi,debian and one secret, GH, bound to api.github.com, and then, by hand with tkbox exec, installed curl (off camera, through the debian preset) and requests and tried each way out: a host off the allowlist (example.com), the cloud metadata address, and the GitHub token sent to its bound host.

Figure 12 (a)

(a) tkbox run creates the box and registers its Tracekit run (5c3cdfc5…, the run in the bundle). Box prints the hosts the allowlist expands to and the one header the secret may go in.

Figure 12 (b)

(b) A host off the allowlist is held for a person on the host (tkbox approve); curl gave up after 5 s and no one approved it. The cloud metadata address is refused before any decision, with 403.

Figure 12 (c)

(c) Inside the box $GH is only a placeholder. Sent in the Authorization header to api.github.com, its bound host, it works: the proxy swapped the real token in. (The account name in GitHub’s answer is redacted here.)

Figure 12 (d)

(d) A file written in the box comes back only as a git-format patch.

Figure 12: The demo run, as recorded. Each panel is cut from the screen recording of the session that produced the bundle in table 13; lines longer than 104 columns are wrapped, and nothing else is changed except the redaction in (c). Figure 8 shows the same session’s stop, export and verify.

Table 13 breaks down the 102 records of the bundle; fig. 13 shows the decisions. The bundle verifies with Tracekit 1.4.2 (fig. 8).

Table 13: The demo run’s signed record (runs/by-hand/readme-demo).

RecordCount
policy.decision (Bash and WebFetch)46
tool.result (43 ok, 3 not ok)46
input.observed: tkbox.box3
 tkbox.egress (Box’s own refusals)2
 tkbox.secret.use1
 tkbox.approval1
run lifecycle (run.registered, run.closing, run.final)3
Total records102
Verified Runs table: commands / connections / held / refused7 / 39 / 1 / 2
Checkpoint tree size5,978
IntegrityVERIFIED
Assurance; isolationdev; same-user

Figure 13

Figure 13: The demo run’s 46 signer decisions. Every WebFetch was flagged by rules TK-F002 and TK-F012 of the signer’s default packs; a flagged call is allowed and carries the finding in its record. The 39 include example.com: the signer decided it first, then Box held it for a person, and when no one approved it Box refused it and recorded tkbox.egress. The metadata address is not among them: Box refused it before any decision.

Three details of this record are worth noting. First, the counts in the Verified Runs table are computed from the verified records by make runs, not typed: 7 commands, 39 connections, 1 held, 2 refused. Second, the cloud metadata address never reached the signer: Box refused it at the door and recorded tkbox.egress, so it is counted under refused but not under connections. Third, the held host (example.com) appears both as held and refused, because no one approved it and the hold ended in a refusal that Box recorded. Those two refusals are the two tkbox.egress observations in table 13; example.com also has its WebFetch decision, because the signer is asked before a person is. The record shows exactly one secret use, by name and host, and the token’s value appears nowhere in the bundle.

Coverage, as Tracekit sees it. The verifier’s coverage matrix (fig. 8) reports network and tool calls as “reported (adapter)” and processes as “not covered”. That is Tracekit’s generic, conservative view: it does not know that inside a box the network has no other route. The record is complete for the doors because of the box, not because of anything the bundle can prove on its own; a reader who trusts the bundle must also trust that it came from a box, which the tkbox.box observation states but cannot prove at dev assurance.

7.3 Fuzzing the trust boundary

Every parser that reads bytes from the guest, or from an upstream host on the guest’s behalf, is a place where the guest can attack the host. Table 14 lists the 15 fuzz targets. Each is stated as a property, not just “does not crash”; for example FuzzClientHello requires that a ClientHello Box admits names the CONNECT host, and only it, under three different readings of the same bytes (Go’s crypto/tls, Box’s own parser, and a reading that takes every server_name entry of any type).

Table 14: The 15 fuzz targets and the property each one checks.

TargetPackageProperty
FuzzAddrFormsegressany 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
FuzzHostTextegresshost text accepted as an IP literal is checked as that literal; text accepted as a name is not one inet_aton reads as IPv4
FuzzClientHelloegressan admitted ClientHello names the CONNECT host and only it, in every reading; exactly one ClientHello is forwarded
FuzzForwardegressthe upstream gets the path and query Box checked and recorded; the guest never sees a secret value a bound host echoed
FuzzRedactReaderegressa 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
FuzzReadFrameprotoany byte stream decodes to bounded frames, allocates at most one bounded payload per frame, and re-encodes exactly
FuzzChunkWriterprotoany write splits into bounded frames that concatenate back to the input
FuzzClientRepliesagentrpcwhatever a hostile guest answers, the host client returns promptly, stays within its caps, and accepts a diff only with clean, relative, .git-free paths of the declared size
FuzzParsePatchdaemonno panic; every path apply would decide and write is relative, clean and inside the root
FuzzParsePatchVsGitdaemonwhen the parser accepts a patch, git apply --numstat names no path the parser did not return
FuzzShellQuotetracekitthe signed command line parses back to exactly the argument vector, and its first word is the program that runs
FuzzDebugfsQuoteimagea quoted path never breaks out of its line or quotes in a debugfs command file
FuzzFixupsimageevery debugfs fix-up addresses a file that exists in the staged tree
FuzzParseconfigany tkbox.yaml fails or yields a config whose limits are in range, whose secrets have one source and exact hosts, and which validates again
FuzzParseSecretFlagconfig--secret parses only to a valid secret; a refused flag’s error never shows what may be a pasted value

Findings. Fuzzing produced nine findings, all fixed (table 15): eight from the campaign that wrote the targets, and one (B2) that FuzzRedactReader found later in the repository’s own make fuzz run. Six are in the egress proxy, where the guarantees on secrets and addresses depend on them. F3 is the most serious: Go’s net/http parses GET https:http://evil.example/x as an opaque URL with no host, so the inner-request check passed it, and the proxy then wrote it upstream as an absolute-form request for evil.example with the real secret swapped in. A front end that routes by absolute-form target could have handed the credential to another site while the record named only the bound host. F2 let a response with a status below 100 relay its body unredacted, so a bound host echoing the swapped value could have shown it to the guest. F4 allowed a redaction marker to combine with neighbouring bytes into the value it replaced, and B2 let a body read in pieces keep parts of a secret whose value contains another secret’s.

Table 15: Fuzzing findings, all fixed, with the labels used in the repository’s tests and commits.

IDAreaFinding and fix
F1patch 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
F2egressa response status below 100 had its body relayed without redaction; now a 502
F3egressan opaque request target inside a TLS tunnel could carry a swapped secret to another host; now refused
F4egressa redaction marker could rebuild a value with the bytes around it; markers can no longer take part in a match
F5commandsa program name starting with % is a job spec to bash and zsh, so the decided command line would not name what runs; now refused
F6configa refused --secret flag could quote part of a pasted value in its error; the source is now checked first and never quoted
I1egressan IPv6 --egress-block prefix spanning the IPv4-mapped range did not refuse the IPv4 addresses it covers
I2egressSRv6 segment IDs (5f00::/16) were missing from the refused ranges
B2egressthe streaming redactor, reading a body in pieces, could leave parts of a secret whose value contains another secret’s; found by FuzzRedactReader after the campaign

7.4 Review

Before release the code went through rounds of independent review, followed by verification passes that checked each fix and each claim in the documentation against the code. The README counts three independent review rounds plus the fuzzing campaign; the commit history labels fixes from each pass (table 16). The findings were of the same kind as the fuzz findings: ways a hostile guest, a hostile upstream or a hostile patch could make a door do something other than what the record says.

Table 16: Review passes, as labelled in the commit history, with examples of what each fixed.

PassExamples of fixes
review-1a CONNECT to 443 under an entry naming 443 recorded as tcp://; base64 values that are JSON-escaped or percent-encoded redacted
re-reviewgaps in apply, stdin, the observer, events and MCP limits
verify-3, 3bdocumentation claims corrected; apply stated plainly to have no rollback
verify-5more 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 checksa 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

7.5 Summary

Table 17 collects the numbers this section reports.

Table 17: The evaluation at a glance.

ItemResult
End-to-end checks on KVM (CI run #16)13 / 13 pass
TestBox (the 13 checks), including pull and boot28.5 s
Unit tests; end-to-end test functions340; 2
Fuzz targets; findings fixed15; 9
Demo bundle: records; decisions; verifies102; 46; yes
Assurance of every bundle reporteddev

8 Discussion

8.1 Limits

Box v0 is a first release. Its limits fall into three groups: what the design does not try to do, what the implementation does not do yet, and what the evaluation has not shown.

By design.

  • Inside the VM is not recorded. Box records doors, not processes. A command started from the host is decided; the processes it starts are not, and an agent that runs a script which runs a hundred commands leaves one Bash decision and whatever those commands did at the doors. Box v0 runs no process sensor in the guest.
  • Who, not what. Box decides which hosts the agent may reach, not what it sends them. Any allowed host that fetches URLs for its callers is a relay: go-proxy fetches modules from whatever host a module path names, so an agent can send workspace data to a domain of its choosing through an allowed host. The documentation says so, and the only fix is a smaller allowlist.
  • Uninspected connections. With the default inspect: bound, the proxy reads only connections to hosts with a bound secret, and plain HTTP. On other HTTPS hosts it sees the host, port and server name. A secret’s value cannot leave there, because it is not in the VM; its placeholder can, and is useless to the receiver. inspect: all reads every allowed HTTPS host, at the cost of breaking clients that pin certificates.
  • Only /workspace comes back. Changes elsewhere in the VM are lost at stop.
  • No rollback. tkbox apply decides every file before it writes any, and re-checks the checkout before writing, but if a write fails midway it names exactly which paths were written and does not undo them.

Not in v0. Table 18 lists what is not built yet.

Table 18: Not in v0, and what happens today.

Not in v0Today
Firecracker jailerFirecracker 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 signerthe signer must be on the daemon’s host (Unix socket only)
Private registriespublic images only
Snapshots and restoreeach box boots from its image; a stopped box cannot be resumed
Kubernetes RuntimeClassrun tkbox daemon on the node or runner directly
gVisor fallbackno /dev/kvm, no daemon
macOS hoststhe CLI builds on macOS; boxes need Linux (a Lima VM works)
SSH and UDP egressHTTPS and HTTP only; no DNS, QUIC or NTP from the box
GPUs, Windows guestsnone; Linux images only

Operational caveats.

  • The host’s public address on a cloud VM. Box refuses the host’s own addresses as it sees them on its interfaces. On EC2, GCE, Azure and GitHub-hosted runners the public IPv4 is translated and on no interface, so a box allowed to reach it could reach services the host listens on, unless the operator passes it with --egress-block.
  • Same-user access. The daemon’s API accepts any process of its own user. An MCP orchestrator that can also run a shell as that user can bypass MCP’s narrowing and approve its own connections. Operators should run the orchestrator as another user.
  • Containment latency. A hold or quarantine set on the signer takes effect within 2 s in a box’s first minute, and within about 15 s after that, because the daemon polls.
  • ECH and HTTP/2. Clients that send an encrypted ClientHello are refused, since Box must see the server name; Chromium-based tools may hit this. Inspected connections speak HTTP/1.1 only.

What the evaluation has not shown. As section 7 says: no performance numbers beyond the suite’s wall-clock times, no study with a capable agent trying to get out, and no deployment above dev assurance.

8.2 Why doors, and not a smarter guest

An alternative design would put monitoring inside the guest: a syscall tracer, an eBPF sensor, a hardened agent runtime. We chose the opposite because everything inside the guest is the attacker’s. A monitor in the guest can be killed, starved or lied to by code running as root next to it, and its records would have to be trusted on the guest’s word. A door on the host cannot be bypassed from inside, because there is no other way out of a VM with no network card, and the record of what passed the door is made by the host, not reported by the guest. The cost is the first limit above: Box sees only what crosses a door. We think this is the right trade for the actions that cause harm outside the box, which are exactly the ones that must cross a door to cause it.

8.3 Future work

  • Jailer and stronger host hardening. Running Firecracker under its jailer (chroot, namespaces, cgroups, a per-box user) is the most important missing piece of defence in depth.
  • Remote signer and witnesses. A signer on another host, reached over an authenticated channel, and a witness that cosigns checkpoints, so that a bundle proves more than dev assurance.
  • An adversarial agent study. Put capable agents in boxes, with instructions or injected text that tell them to get data or a key out, and report what they try and where it is stopped.
  • Performance. Boot latency, exec overhead, proxy throughput, and the cost of a signer decision per connection, against containers and other micro-VM sandboxes.
  • Processes inside the VM. A guest sensor whose records are reconciled against the doors, so that a gap between what the guest says it ran and what reached a door is visible.
  • Deployment. Private registries, snapshots for fast start, and a Kubernetes RuntimeClass.
  • More Verified Runs. One for every agent family, so the table measures how real agents behave inside a box.

9 Conclusion

Tracekit Box runs an AI agent in a Firecracker micro-VM whose only ways out are doors on the host, and puts a Tracekit signer at every door. Commands, connections and file writes are decided before they happen; secrets stay on the host and are used on the agent’s behalf only in one header for exact hosts; the agent’s work comes back as a patch applied file by file; and every decision, use and refusal is signed into one run per box that anyone can verify offline. The v0 implementation passes all of its end-to-end checks on real KVM hardware, its trust-boundary parsers are fuzzed against stated properties, and the issues that fuzzing and review found are fixed. Its limits are stated plainly: it records doors, not processes inside the VM; it decides who the agent may reach, not what it says; it does not yet run the jailer; and every bundle reported here has dev assurance. Within those limits, it gives an operator something sandboxes and tracing do not give separately: a box the agent cannot leave unseen, and a record of what left it that the operator does not have to take on trust.

Acknowledgements

We thank the Firecracker maintainers, whose virtual machine monitor Box builds on, and the authors of the C2SP and Certificate Transparency specifications behind Tracekit’s evidence format. Any errors are ours.

References

  1. A. Agache, M. Brooker, A. Florescu, A. Iordache, A. Liguori, R. Neugebauer, P. Piwonka, and D.-M. Popa. Firecracker: Lightweight virtualization for serverless applications. In 17th USENIX Symposium on Networked Systems Design and Implementation (NSDI), 2020.

  2. Anthropic. sandbox-runtime: operating-system sandboxing with network filtering for agents. https://github.com/anthropic-experimental/sandbox-runtime, 2026.

  3. E. Debenedetti, J. Zhang, M. Balunović, L. Beurer-Kellner, M. Fischer, and F. Tramèr. AgentDojo: A dynamic environment to evaluate prompt injection attacks and defenses for LLM agents. In Advances in Neural Information Processing Systems, Datasets and Benchmarks Track, 2024. arXiv:2406.13352.

  4. E2B. E2B: Open-source cloud sandboxes for AI agents. https://github.com/e2b-dev/E2B, 2026.

  5. B. Ghosh. Tracekit: Tamper-Evident Intent–Reasoning–Action Auditing for AI Agents. arXiv:2609.35659, 2026. Code: https://github.com/Cygnux-Labs/Tracekit.

  6. K. Greshake, S. Abdelnabi, S. Mishra, C. Endres, T. Holz, and M. Fritz. Not what you’ve signed up for: Compromising real-world LLM-integrated applications with indirect prompt injection. In Proceedings of the 16th ACM Workshop on Artificial Intelligence and Security (AISec), 2023. arXiv:2302.12173.

  7. Kata Containers project. Kata Containers. https://katacontainers.io, 2026.

  8. B. Laurie, A. Langley, and E. Kasper. Certificate transparency. RFC 6962, 2013. URL https://www.rfc-editor.org/rfc/rfc6962.

  9. microsandbox contributors. microsandbox: self-hosted micro-VM sandboxes for untrusted code. https://github.com/superradcompany/microsandbox, 2026.

  10. NVIDIA. OpenShell: a sandbox runtime for AI agents. https://github.com/NVIDIA/OpenShell, 2026.

  11. OpenAI. An agent used DNS to reach an external chatbot. OpenAI Alignment blog, misalignment reports, 2026. URL https://alignment.openai.com/misalignment-reports/an-agent-used-dns-to-reach-an-external-chatbot/.

  12. E. G. Young, P. Zhu, T. Caraza-Harter, A. C. Arpaci-Dusseau, and R. H. Arpaci-Dusseau. The true cost of containing: A gVisor case study. In 11th USENIX Workshop on Hot Topics in Cloud Computing (HotCloud), 2019.

  13. Q. Zhan, Z. Liang, Z. Ying, and D. Kang. InjecAgent: Benchmarking indirect prompt injections in tool-integrated large language model agents. In Findings of the Association for Computational Linguistics: ACL 2024, 2024. arXiv:2403.02691.

Appendix A Quick look

On a Linux host with KVM, with Tracekit 1.4.2 installed:

pip install tracekit-ai==1.4.2
tracekit init --dev --v2 --no-hooks && tracekit up --wait   # a same-user dev signer
tkbox daemon &                                 # refuses to start without the signer
tkbox run --image python:3.12-slim --allow pypi,debian --workspace . --name job \
          --secret GH=env:GH_TOKEN@api.github.com
tkbox exec job -- sh -c 'apt-get update -q && apt-get install -y -q curl'
                                               # the image has no curl: debian preset
tkbox exec job -- pip install requests         # allowed by the pypi preset
tkbox exec job -- curl -sI https://example.com # held: answer with `tkbox approve <id>`
tkbox exec job -- sh -c 'echo $GH'             # tkbox_GH_<32 hex>, never the token
tkbox stop job                                 # saves the patch
tkbox diff job                                 # review it
tkbox apply job                                # file by file, each decided by the signer
tkbox export job -o run.tkb                    # run.tkb + run.trust.json
tracekit verify run.tkb --trust run.trust.json # Integrity: VERIFIED.

Appendix B Example configuration

tkbox.yaml at a repository root, abridged from examples/tkbox.yaml. Flags given to tkbox run override these values.

image: python:3.12-slim
name: service-agent
workspace: .            # copied into /workspace: never .git, never credential files
allow: [pypi, github, anthropic]   # anything else waits 2 minutes for `tkbox approve`
secrets:
  ANTHROPIC_API_KEY:
    from_env: ANTHROPIC_API_KEY
    hosts: [api.anthropic.com]     # exact names, no wildcards
    header: X-Api-Key
  GH_TOKEN:
    from_env: GH_TOKEN
    hosts: [api.github.com, github.com]
    header: Authorization
env:                    # visible in the box and recorded: never secrets
  PIP_DISABLE_PIP_VERSION_CHECK: "1"
limits:
  cpus: 2
  memory: 4GiB          # writes outside /workspace use this memory
  disk: 8GiB            # for writes to /workspace
  timeout: 1h           # the box's lifetime
  exec_timeout: 30m     # each command's
inspect: bound          # all: read every allowed HTTPS host; refuse unlisted hosts at once

Appendix C Default limits

Table 19: Selected defaults (the full list, with each message Box prints, is in the repository’s docs/limits.md).

LimitDefault
vCPUs; memory; workspace disk2; 2 GiB; 4 GiB
Box lifetime; each command1 h; 30 min
Boxes per daemon; concurrent execs per box8; 64
Open connections per box256
Pending approvals per box; new approvals per minute16; 30
Approval wait2 min
Connect, resolve and TLS handshake timeouts10 s each
Idle connection5 min
Request head; upstream response head64 KiB; 1 MiB
Up-front stdin; decided as text if UTF-8 and at most1 MiB; 4 KiB
Secret value size64 KiB, one line
Patch size64 MiB
Workspace size100,000 files, 4 GiB
Must-keep observation queue4096
Box’s own refusal records20/s per box
Containment poll2 s (first minute), then 15 s

Appendix D Glossary

Box One Firecracker micro-VM running one agent job, with its own egress proxy, CA and Tracekit run.

Door A channel out of the box that the host serves and the signer decides: commands, network, secrets, model calls, changes, stop.

Decision A signer evaluation of a tool call (Bash, WebFetch, Write) before it happens: allow, flag, ask or deny.

Observation A signed record the signer does not decide, such as tkbox.secret.use.

Placeholder The value tkbox_<NAME>_<32 hex> that the VM sees in place of a secret.

Bound host, bound header The exact hosts, and the one header, where the proxy swaps a placeholder for the real value.

Approval A person on the host allowing or rejecting a connection to a host off the allowlist with tkbox approve or tkbox reject.

Gap A tkbox.gap observation that counts records Box had to keep and could not.

Bundle The .tkb file tkbox export writes: a box’s run, its checkpoint and its keys, verifiable offline with its trust file.

Assurance What the verifier says about who could have signed the records: dev for a same-user dev signer.

Cite

@misc{ghosh2026tracekitbox,
  author        = {Bravish Ghosh},
  title         = {Tracekit Box: Running AI Agents in Micro-VMs Where Every Way Out Is Decided and Signed},
  year          = {2026},
  eprint        = {2610.38458},
  archivePrefix = {arXiv},
  url           = {https://arxiv.org/abs/2610.38458},
  note          = {Code: \url{https://github.com/Cygnux-Labs/Tracekit-box}}
}
← Back to Research