Skip to content
LoopGlitch26 edited this page Oct 20, 2026 · 2 revisions

Known limits and trade-offs

Every limit of Tracekit is written down next to the feature it belongs to, which makes it easy to miss when you read only one page. This page collects them in one place. Each release's notes link here, and a doc that adds a "Limits" section must be linked from this page (tests/test_docs.py checks it).

Each row says what the limit is, why it is so, what to do about it, and where the details are. Rows marked (1.1) describe behaviour new in 1.1, not in 1.0; (1.2), new in 1.2; (1.3), new in 1.3; (1.4), new in 1.4.

What Tracekit guarantees and doesn't, per deployment, on one page: guarantees.

Evidence and verification

Limit Why it is so What to do Details
A verified bundle doesn't prove intent. A record says a call was made, not why. Read decisions, approvals and reasons alongside the records. FAQ
A verified bundle doesn't prove that everything the agent did was recorded. An action that crosses no capture layer (a subprocess, code that skips the adapter, a call around a gateway that isn't mandatory) leaves no record. (1.2) What some layer saw and nothing reported is now a signed gap: a call allowed or approved that never completed (decided_not_completed), a tool result the gateway saw go back with no decide (reconcile.hook_missing, at once), client counter values never received (client_counter_gap, also at close_run), writes after run.final (late_activity), a second run from inside a bound harness process (concurrent_run), a tailer that stops (tailer_lost) and a second tailer token for a run (tailer_redelegated). A call no layer saw still leaves nothing. Read the coverage, tiers and gap lines; add an out-of-process layer (the LLM gateway, the tool executor, the MCP gateway). FAQ, verdicts
A verified bundle doesn't prove that a tool result the agent reported is true. In-process capture (T1) records what the agent's process reported; only out-of-process capture (T2) sees what crossed a boundary. Prefer T2 evidence for calls that matter; use executor: t2 approvals. FAQ, approvals
A tool.result's output.size is the size of what the signer received. For a tool whose result the adapters send as its digest (every tool the policy doesn't list as untrusted), that is the digest's size, not the result's. The signer records what it can check; a size the client claimed would be the agent's word. Read output.size as the size of the content committed to; list a tool under untrusted: to have its result itself sent, redacted and committed to. privacy
Imported OpenTelemetry (T3) proves only that spans were received. Spans are exported after the work is done, by the application. Gate calls with an adapter or hook; treat T3 runs as a log, not evidence of completeness. OpenTelemetry
agentgateway's MCP spans import without arguments or results, and each request is its own trace, so its own run, unless the agent sends traceparent. agentgateway's spans name the tool and its outcome; the import keys runs by trace id. Add gen_ai.tool.call.arguments and gen_ai.tool.call.result through agentgateway's tracing attributes; send traceparent from the agent; for evidence of what ran, use the ext_mcp processor. agentgateway
An imported run closes when its trace's root span arrives; a trace whose root never reaches the signer (its root is another process's, as with traceparent through agentgateway) stays open until idle_s and counts against the importer's open_runs (100): past it every batch is refused (503, retried) until runs idle out. A span that ends after its root and comes more than grace_s behind it is rejected. The import can't tell a trace is done but by its root; open runs are held in memory. Export the root span to the signer too, raise the importer's open_runs in limits, or lower idle_s. OpenTelemetry
Tracekit is tamper-evident, not tamper-proof: a deletion shows, it isn't prevented. Prevention would need attested hardware (a TEE), which Tracekit doesn't claim. Keep independent witnesses and a monitor so a rewrite is detected. FAQ
Whoever holds the signer's key can rewrite or fork its log; nothing in the bundle alone shows it. Signatures prove who signed, not that the key holder didn't sign again. Pin a witness the operator doesn't run (witnessed) and a monitor (witnessed+monitored). server threat model
Records after the last cosigned checkpoint are unproven. A witness vouches only for what it has cosigned. Export after the next checkpoint; read the log tail warning. verdicts, witnesses
A bundle exported before its run is finalised verifies only TO HEAD n (open). The signer finalises a run a few seconds after it closes. Re-export after run.final; for audits require VERIFIED. quickstart, verdicts
(1.4) A run cut back (records or parts dropped from its end, with an inclusion proof of the record now last) verifies TO HEAD n (open), as an export before its end does: nothing shows that records were dropped. The proofs file isn't signed, and from a run's records an inclusion proof of any of them can often be computed; only the signer's run.final or a run-set says where a run ends. For audits require VERIFIED (the run with its run.final), or a run-set with run-set: COMPLETE. format v2, verdicts
A single run's bundle doesn't show that no other run was left out. The exporter chooses which runs to export. Ask for the tenant's run-set bundle and require run-set: COMPLETE. auditor guide
(1.4) tracekit export --v2 --run-set always starts at registry size 0 and bundles every finished run of the tenant, one file each: past about 10,000 finished runs (the format's 10,000 files) or 512 MiB in all it refuses, saying which limit it is over. The command has no later start, and the format caps a bundle's files and size so that verifying it needs bounded memory. Give each workload or team its own tenant, or start a new data_dir (a new log) before a tenant nears the limit. auditor guide
A withheld key retirement isn't visible unless a run-set from registry size 0 reaches the bundle. A bundle whose key records show one key and no retirement says so only on its Assurance line, not as a warning: a retirement withheld together with its new key would look the same. Only the registry log proves which key.retire records exist. Read key retirements not proven complete on the Assurance line (a keys warning when the key records show a retired key); require a run-set from registry size 0 where it matters. verdicts
key assurance is asserted unless a record-key issuer certifies the keys. Without an issuer the log declares its own record keys. Run an issuer and pin it in the trust config. issuer
The verifier doesn't fetch revocations. Verification is offline and takes trust only from the verifier's side. Pass the issuer's log with --revocations. issuer
A trust config the operator supplies proves nothing about the operator. The bundle carries no code or trust (I9); whoever writes the trust config decides what verifies. Build trust.json from independent sources and install the verifier yourself. auditor guide
A v1 bundle verified without --key or --witness is only internally consistent (UNANCHORED). A bundle carries its own public key, so a bundle forged with a fresh key is consistent too. Pin the signer's key or an independent witness. laptop threat model
v1: the exporter can replace a selected record with an elided stub, and manifest.json is unsigned. v1 stubs are not bound to a run; the manifest is an index, recomputed from signed records. Use v2 bundles; read the "run completeness unproven" warning. laptop threat model
Record timestamps come from the signer host's clock; seq and the chain prove order only. Only witnesses and RFC 3161 timestamps give independent time. Read cosignature and anchor times in the Assurance line. laptop threat model, witnesses
A v1 record after a v1 → v2 bridge is reported only with --v1-ledger. The frozen v1 verifier sees bridge records as ordinary gaps. Verify bridged logs with --v1-ledger. format v2
The TypeScript verifier reports UNVERIFIABLE for bundles that rely on a Rekor anchor, an SLH-DSA checkpoint signature or certified record keys. Web Crypto has no SLH-DSA, and those checks aren't ported yet; it never says VERIFIED for a check it skipped. Verify such bundles with the Python verifier (pip install tracekit-ai). auditor guide, format v2
A verifier doesn't check what an ext name it doesn't interpret says: the records verify (signature, chain, inclusion), and each such name is a not interpreted warning. Format 1.4's non-critical slot lets a later Tracekit add what an older verifier can leave unread, instead of locking that verifier out. Verify with the version the warning names (tracekit >= x checks it); --strict stops on it. format v2, verdicts
A 1.3 or older verifier reports a bundle with an ext field or record UNVERIFIABLE (needs tracekit >= 1.4.0) and prints no commands to verify it. The slot and the upgrade lines are new in 1.4.0. Run the commands in verdicts. format v2
The browser verifier (verify.html) is the TypeScript verifier: it reports UNVERIFIABLE where that one does (SLH-DSA, Rekor anchors, certified record keys, tracekit why findings) and takes no monitor reports, revocations or v1 ledger; a v1 bundle it names the command for. Without a trust file it is never VERIFIED. In a browser that refuses its worker it verifies in the page itself, which a bundle of 100k records holds for seconds. A copy someone else hosts is only as good as its bytes. It runs on Web Crypto in the page; a browser can't check where a page came from. Use tracekit verify for those checks and for very large bundles in such a browser; get the trust file from the sender; for evidence, open a copy you checked with gh attestation verify verify.html --repo Cygnux-Labs/Tracekit. format v2, releasing
The browser verifier holds the bundle, unzipped, and every record it parses in the tab's memory, about 8 times the bundle unzipped: a run's 100,000 records (50,000 tool calls: a 28 MB bundle, 130 MiB unzipped in three parts) take about 14 s in Chrome, and the same verifier as a CLI 1 GB (E22). A bundle near the format's 512 MiB, some 390,000 records, would need about 4 GB, more than a tab may hold: it can close the tab. Without a trust file the page reads every record, since no pinned key stops a bundle at its checkpoint. It verifies offline, in the page, under the format's limits (512 MiB unzipped); a line shorter than a signed record is refused before any is parsed, so a small file can't unzip into millions of records. Choose the sender's trust file: a bundle their signer didn't sign then fails at its checkpoint before a record is read. Verify very large bundles with tracekit verify or npx @cygnuxlabs/tracekit verify. format v2
A run's records must fit one bundle: 512 MiB unzipped, about 390,000 records (some 190,000 tool calls with small arguments, fewer with larger ones). (1.4) A run past 64 MiB of records (about 24,000 tool calls) is written in parts of up to 64 MiB, which a 1.3 or older verifier can't read: it reports the bundle UNVERIFIABLE (needs tracekit >= 1.4.0). tracekit export refuses a bundle of more than 512 MiB in all or 10,000 files, and says which limit it is over. Both command-line verifiers hold about 8 times the bundle unzipped in memory: 1 GB for 50,000 tool calls, some 4 GB at the limit. The format caps each file of a bundle at 64 MiB and the whole at 512 MiB, so verifying one needs bounded memory; a bundle past them is UNUSABLE BUNDLE to every verifier. Verify a bundle with parts with tracekit 1.4 or newer (Python, TypeScript or the page), on a machine with 4 GB free for the largest. Start a new run for each task or session; a run still open exports up to its head, so export a very long one before it reaches the limit. format v2
The TypeScript verifier needs Node 20.12 or newer (or a current browser). It uses JSON imports and DecompressionStream("deflate-raw"), which earlier Node 20 releases lack. Upgrade Node, or use the Python verifier. TypeScript SDK
npx @cygnuxlabs/tracekit verify with --monitor-report, --revocations or the v1 bridge options runs the Python verifier, which must be installed. Only the Python verifier has those checks. pip install tracekit-ai, or verify without those options. auditor guide
(1.2) A bundle holding a run started by another run (parent_run, a sub-agent's run) needs tracekit 1.2 or newer; a 1.1 verifier reports it UNVERIFIABLE (needs tracekit >= 1.2.0). Every v2 record definition is closed, so an older verifier can't read the new fields; the manifest's verifier_min_version makes it say so instead of FAILED. Verify with tracekit 1.2 or newer (Python or TypeScript). format v2
(1.2) A bundle holding a 1.2 completeness gap (decided_not_completed, late_activity, concurrent_run, tailer_redelegated, a gap's run_id), a run.child record or a run.final with provider_executed needs tracekit 1.2 or newer; older verifiers report it UNVERIFIABLE (needs tracekit >= 1.2.0). The new kinds and fields are outside the 1.1 schema; the manifest's verifier_min_version says so. Verify with tracekit 1.2 or newer. format v2
Experimental surfaces may change between releases and get less hardening than the stable core. Few people run them yet; the stable core is what is tested end to end and fixed first. Build on the stable core; each experimental command or server says so on stderr when it starts. what's stable
JSON nested deeper than 128 levels is refused: a bundle file is unusable, a record fails, and a tool call's arguments are denied (TK-ARGS-INVALID). A fixed depth makes every verifier and the signer read the same documents the same way, whatever their parser's limits. Flatten arguments deeper than that. format v2

Dev mode vs system mode

Limit Why it is so What to do Details
v1 tracekitd: witness publishing (git push, HTTP) runs under the signer's lock, so an unreachable witness stalls appends for up to 15–60 s; a local user can hold the 64 connections by trickling bytes (the dev socket is world-writable); runs.json is rewritten on every append. The v1 daemon is single-writer and laptop-sized. Use the v2 signer for anything shared or busy; keep v1 witnesses reachable. laptop threat model
v1 tracekitd: a restart takes every run of the ledger as active again, so past the run cap it writes run_state_evicted gaps for long-finished runs, and a run that never sent run.start loses its owner. Parallel hooks of one session can record a false trace.tamper (transcript length read before the send). Client state files (~/.tracekit-client/runs/, Gemini call ids, transcript acks) are never pruned. v1 state is rebuilt from the ledger and kept per laptop. Read those gaps as restart artefacts; prune ~/.tracekit-client/runs/ by hand. laptop threat model
Same-user dev mode protects nothing against the agent itself. The dev signer runs as your user, so an agent that runs commands as you can read its key, stop it, or rewrite and re-sign its store. Policy rules (TK-D007, TK-D013) are tripwires. Use system mode, a sidecar or a central signer for anything you need to rely on; dev bundles say isolation: same-user. FAQ, quickstart
Assurance describes checkpoint cosigning, not isolation: a same-user signer with a witness verifies as witnessed. The two are independent properties. Read isolation as well as Assurance. verdicts, witnesses
A system-mode signer without a witness verifies as Assurance: dev. Nothing outside the host shows the log wasn't rolled back. Add a witness on another host that the operator doesn't run. deployment
System mode doesn't protect against root on the machine. Root can read the key and rewrite the store. Witnesses on another host detect a rewrite of what they cosigned. deployment
In system mode the agent can remove the hook from its own ~/.claude/settings.json, or act outside Claude Code, with no record and no gap. The hook lives in the agent user's settings. Use admin-managed settings (init --managed, v1) or an out-of-process record (gateway). quickstart
v1 system mode (sudo tracekit init without --v2) has no uninstall: tracekit uninstall takes the hooks out of user and project settings only, not out of admin-managed settings (--managed), and leaves tracekitd's service, /var/lib/tracekit, /opt/tracekit and /etc/tracekit/client.json. v1 system mode is the legacy path; sudo tracekit uninstall --v2 removes v2 system mode. Remove Tracekit's hook groups from the managed settings file, stop and remove the tracekitd unit (or launchd plist), then those paths. quickstart
Without harness binding, any process of the agent's user can write into the agent's runs or start runs of its own. The signer knows the caller by uid; run tokens are readable by that uid. Register the harness (--harness, Linux). quickstart
System mode requires an agent user that is not root and not in a sudo, wheel, admin, docker or similar group. Such a user could take over the signer. tracekit doctor (D-AGENT-PRIV) checks it. doctor
When the transcript tailer's ACL or sudo rule can't be set up, every run records a tailer_lost gap instead. The tailer needs read access to the agent's transcripts. Fix the setup init reports. deployment

Signer and capture

Limit Why it is so What to do Details
A tool class set to fail open runs unrecorded while the signer is down. It is the setting you choose when an outage must not stop the agent; the next call writes a signed gap for exactly the missed calls. Keep fail_modes closed (the default); the report warns on every fail-open class. FAQ
While the signer is unreachable, the LangChain adapter and the Claude Code hook pick a call's fail mode by the class the shipped default policy gives the tool (terminal is shell, an unmapped tool unknown); the MCP and Browser Use adapters by their fixed class (mcp, browser); the OpenAI Agents, Claude Agent SDK and CrewAI adapters apply default to every tool. A signer whose policy maps the tool to another class may have another fail mode for it. The tool's class is the signer's decision, and the signer can't be asked. Keep fail modes closed for classes your policy remaps, or set default; with OpenAI Agents, the Claude Agent SDK or CrewAI, set default. FAQ
Model calls (autotrace) while the signer is down: under a closed model fail mode a request that can't be recorded is refused (PermissionError, saying so and what to do); a response the signer missed (it went down mid-call) is sent again at the next model call or at exit, after a signed client_counter_gap for the try that failed. A signer that stays down until the process exits leaves the request without its response, and the run open until the signer's idle timeout. The client records only while the signer answers, and keeps missed responses in memory (the newest 1024). Keep the signer running (system mode restarts it); set fail_modes: {model: open} to let model calls run unrecorded during an outage. FAQ
v1 coding-agent hooks allow a call when the payload can't be parsed or the harness is unknown. (1.2) v2 hooks that tracekit init wrote from 1.2 on name their event, so a pre-tool payload that can't be parsed is blocked; a post-tool one is reported on stderr, and the call it ends is completed as an error at the session's next Stop, prompt or end. v2 hooks installed before 1.2 still let an unparseable payload through, unrecorded; tracekit doctor (D-HOOKS-EVENT) finds them for Claude Code only. Broken input never blocks an agent in v1. A hook that can't parse its payload knows its event only from the command line. The harness writes the payload, not the agent. Doctor reads only Claude Code's settings. Re-run tracekit init after upgrading (--agent NAME for Codex, Cursor and Gemini CLI, which doctor doesn't check); in v1 set TRACEKIT_FAIL_CLOSED=1 or fail_mode: closed. coding agents, doctor
The signer can't tell a real event from a well-formed fake sent by the agent's identity. Anything that can call the signer as the agent can submit plausible events. Cross-check with an independent layer: the gateway (gateway_mandatory) or, in v1, the model proxy. laptop threat model, signing
Compromised application code can skip the adapter for some calls, and suppress in-process autotrace (L3) too. Both run in the agent's process. Run the LLM gateway outside the agent; read reconcile.* and (L3 absent) in the report. server threat model
Capture layer L5 (services) is not shipped, and L4 (processes) only as the experimental kernel sensor import (Linux): a sensor that covered a run throughout makes its processes observed, and its effects are the sensor's word, not reconciled. (1.2) L6 (network) is the egress observer, hosts only. Out-of-process capture of services isn't built yet; the coverage matrix says not covered for them, and for processes where no sensor covered the run. On Linux, import a kernel sensor (Tetragon, Falco); use the LLM gateway, the executor and the egress observer as the other out-of-process records. server threat model
SDK and adapter capture is opt-in per call: only wrapped calls are seen and gated. An adapter sees only the calls routed through it. Wrap every tool; each integration lists what it doesn't cover. laptop threat model, OpenAI Agents, MCP, Browser Use, Claude Agent SDK, CrewAI, Pydantic AI, Google ADK, Haystack, LlamaIndex, Microsoft Agent Framework, Strands
Actions inside subprocesses and background processes, and faked command output, aren't observed by the hooks or adapters. They see the command line; tool results are what the harness reports. On Linux, import a kernel sensor's records into the run (experimental): the processes, connections and file writes a command started are signed, and tracekit analyze --v2 flags one no call explains. Elsewhere, run the agent in a sandbox that logs them. laptop threat model
Nothing is recorded after the session's last hook, including transcript edits. Nothing runs to see it. Treat the run's end as the last observed point. laptop threat model
(1.2) decided_not_completed is written at run.final only, and doesn't say why the result never came (the agent stopped or crashed, its post-call hook didn't run, or a client dropped it). Calls denied, or asked and never approved, get none: they may not run; a call the executor or a gateway holds gets held_call_unresolved instead. A decision is also exempt when a later decision of the same call completed, except one whose approval was consumed and whose call didn't complete with the approved arguments; only the call's latest decision counts as later, so an open decision before a completed one and an open one after it is a gap too. The signer sees the decide and the missing complete, not the agent or the harness's prompt. At most 100 per run are listed, then one gap counts the rest. Read the gap next to the run's other records and the transcript or app log; a crash usually ends the run early too. coding agents
(1.2) A coding-agent hook completes each call that got no post-tool event (declined in the harness's own permission prompt, interrupted, or a post-tool payload it couldn't read) as an error when the session ends. That result is the hook's word, not the harness's; a session killed before its end event leaves such calls as decided_not_completed gaps instead. No hook event says that a call was declined or interrupted, and a background sub-agent's call can still run when a turn ends; the session's end is the first point where none can. Read a tool.result error "no PostToolUse came" as "not seen to run". coding agents
(1.3) A coding-agent hook blocks only tool calls. A prompt, Stop, tool result or session event whose hook fails (the signer unreachable, tracekit missing or broken) runs unrecorded, with a hook error in the transcript. Hook commands tracekit init wrote before 1.3 still turn such a failure into a block. On a prompt, a block erases it; on Stop, it sends the agent back to work in a loop. Every tool call is still decided before it runs. Read hook errors in the transcript; run tracekit init again after upgrading to get the 1.3 commands (tracekit doctor flags old system-mode ones, D-HOOKS-VENV). plugin
(1.2) concurrent_run covers only runs bound to a harness process (harness binding, Linux system mode): a second run started from inside the same harness process while another session's run is open there, once both have recorded an event since it registered. SDK and server runs, which run several at once by design, and harnesses without binding get no such check. A run started with parent (a sub-agent's) gets none either: the agent can read its session's run token, so a second session can pose as a child, and shows only as a run.child record in the parent. Which runs overlap is kept in memory and snapshots: a signer restart with no snapshot since forgets a pending one. One harness process drives one session at a time; without a binding the signer can't tell a forged second run from a legitimate parallel one, and an older session's run left open and idle is no overlap. Turn on harness binding for coding agents; pass parent for a sub-agent's run; read each run.child against the harness's transcripts. format v2
(1.2) A killed transcript tailer is a tailer_lost gap only in system mode, once it has made no call (heartbeat or record) for 90 s while the run is open, counted from when the hook took its token, and only while the signer runs: the watch is kept in memory. In dev mode the tailer runs as the agent's user, which may not send heartbeats, so a killed tailer leaves no gap. Heartbeats show that a process holding the run's tailer token is alive, not that it reads the session's real transcript: the agent's user starts the tailer (through sudo) and names its transcript, so it can stop the real one and start one of its own on a file it writes. A second tailer token for the run is a tailer_redelegated gap, but a decoy handed the first token (the agent's user can read the hook's memory) isn't. A tailer that keeps its heartbeats but stops reading is caught only by its idle limit. The signer can't watch the tailer's process or see the transcript; the heartbeat is its only signal, and only an identity the agent doesn't control may send it. Use system mode (sudo tracekit init --v2) where a killed tailer must show; compare the run's transcript records with the transcript itself where it matters; re-check runs that span a signer restart. coding agents
A session's later run (the signer closed the earlier one after an hour idle, or the session was resumed) records its transcript from where the hook last saw it while the earlier run was open: the lines Claude Code wrote after that run's last hook call (most often the closing text of a turn) can be in both runs. A resumed session is read from where its transcript (Codex's rollout, Gemini CLI's chat recording) ended at SessionStart, so a harness that writes the earlier history only after that hook repeats it in the new run. The hook can't see how far the earlier run's tailer got; it measures the transcript at its own calls. Read a session's runs in order (tracekit view --dev): each holds what was written while it was open. coding agents
(1.2) Writes refused because their run is final are counted per run in memory and written about once a minute as a late_activity gap in the tenant's signer run; counts since the last write are lost if the signer stops uncleanly. A close_run never counts, nor does the first write of each identity and stream (up to 64 per run): a client learns from it that its run ended, after an idle close, and moves to a new run. A run-set lists them with the tenant-level gaps. The writes can't go into a final run, and the signer-level refusal summary doesn't name the run. Read late_activity in a run-set; if it recurs, check that each client registers a new run once its run ends. format v2
(1.2) Trailing client counter values become a gap only when the client says where its stream ended (close_run last_client_seq), and only for streams the closing identity wrote (or that never reached the signer); after a signer restart with no snapshot since, a stream written before it can't be ended that way. The LLM gateway says so when it drops a run from memory or stops (SIGTERM or Ctrl-C; a killed gateway says nothing); for a run already final the gap goes in the tenant's signer run, naming the run. A gateway whose identity lacks the close_run grant logs the refusal on stderr and leaves no gap. The signer can't know of values a client never sent, so the gap is the closing identity's word, and its reason names that identity; values up to there that still arrive in the grace period are recorded with no second gap. Close runs with last_client_seq from your own clients; grant gateways close_run and stop them with SIGTERM; read a gap's reason for who reported it. gateway
(1.2) A live reconcile.hook_missing (written mid-run, for a detector) comes from the LLM gateway's records only; agent-reported L3 is still reconciled at run.final. Agent-reported L3 comes from the process being checked. Run the gateway for calls that matter. gateway
Harness binding doesn't stop a process the agent starts inside the harness session, or the agent's user starting its own instance of the registered harness. Both are legitimately below, or are, the registered program, running as the agent's user. Run the harness under another user, or use a mandatory gateway. FAQ
The LLM gateway's record is complete only when the agent can't reach the model around it. The gateway sees only requests sent to it. Let only the gateway reach the provider (network policy) and set gateway_mandatory: true. gateway
A tool the policy's structured_output: names gets no L3 cross-check: a decide for it needs no tool use in the model's response, and a tool use of it needs no decide, even under gateway_mandatory, so any tool of that name runs with no decide unseen. The browser pack names Browser Use's actions (browser_use:*) only; its output tool with Anthropic models (AgentOutput) is the deployment's to name. A child policy can't remove a name its parent declares. Browser Use asks the model for its actions as structured output, which carries no tool use id to match a decide with. Read Browser Use's records as decided by the signer, not as proven asked for by the model; declare only names whose calls really come as structured output and that no other tool of yours uses. gateway, Browser Use
(1.2) Tools the provider runs itself (web search, code execution, hosted MCP, ...) can't be held for a decision: they run mid-response. The gateway records their input where the provider returns it (not for image generation) and can only refuse a whole request that declares one (provider_tools). OpenAI shell and tool_search tools count as provider-run, since the provider may host them: with allow, list them if your client runs them. No client-side step happens between the model asking and the provider running them, and a request doesn't always say who runs those two. Deny provider tools you can't accept in gateway.yaml provider_tools (mcp covers every hosted MCP server, mcp:<label> one); the client gets a 403 naming them. gateway
(1.3) Verified tool results catch an edited tool result for a call the signer already completed, and record a result sent before its call completed. They don't catch text the agent adds outside tool results; a tool call the agent writes into the history itself with its result (reconciliation flags it at run.final, result_without_call or, under gateway_mandatory, fabricated, unless the agent also forges a decide, a result and a model report with gateway_mandatory off); results past a request's newest 512 or past the 1 MiB of them the gateway digests (newest first), which the agent chooses by adding results of its own: each such call is a signed capture.gap{result_unchecked}, and past the newest 1024 not even that; a request under the token of a run the agent registered itself; a result OpenAI Responses doesn't resend (previous_response_id) after its first send; and, with the signer unreachable and model failing open, a request the gateway forwards unchecked. The check compares what the gateway sees against the signed results of the same run, by tool_call_id: content outside a tool result, a call with no result in that run, and a request never sent or never checked have nothing to compare with. Set gateway_mandatory and let only the gateway reach the provider; keep the model class failing closed; read a run's result_without_call and fabricated records with its mismatches. gateway
(1.3) Verified tool results are off unless signer.yaml sets verify_tool_results. A client whose recorded result isn't the value the model is sent mismatches on honest runs, and refuse then stops the agent with a 403: every result of the Claude Code hooks (a digest of the raw output) and the Claude Agent SDK integration (the structured tool response); in Pydantic AI a tool returning a list, which Anthropic gets as one text block per item; in Google ADK under AnthropicLlm every result but a dict (and a dict with a content key or only result), under LiteLlm a model or dataclass return (ADK sends its Python repr); and a result of several content blocks (Agent Framework, Strands). Gemini requests (ADK's default) don't pass through the gateway, so nothing is checked. Calls decided under ids of the client's own (the MCP integration, the MCP gateway, LlamaIndex's ReActAgent and CodeActAgent) are unchecked, and so are calls completed with no result (a Pydantic AI output function's: the model reads Final result processed.), error results and results whose content redaction changed. A match says the output was passed on unchanged, not that it is true. The signer compares commitments: equal values, or a value and its digest, match; two renderings of the same output don't, and a commitment over redacted or absent content opens nothing to compare. Run record first; set refuse only once tracekit verify shows no result_mismatch on honest runs of your own agent, model and tools. gateway
(1.3) Verified tool results: refuse refuses a request only for an edited result of a completed call. A result sent before its call completed is a signed result_mismatch and the request is forwarded, so an agent can send a result of its own for an allowed call it never completes. A request's results past 1 MiB (as JSON, newest first; a result the run's previous request also sent counts for nothing) are not compared, and neither is any one result over 1 MiB: each such call is a signed capture.gap{result_unchecked} (a gaps warning in tracekit verify), never refused. A complete can be lost on an honest run (the client gives up after its retries) and every later request sends the result again: refusing would stop the run for good. The gateway digests results in pure Python before forwarding (15 MB of numbers took 3.4 s); the cap bounds that work per request, and a gateway that restarted has no digests cached, so refusing unchecked results would stop honest long runs. Read a result_mismatch whose detail says the result was sent before the call's was recorded with that call's decided_not_completed on run.final, and a result_unchecked gap as a result nothing compared; keep the tool results a model reads under 1 MiB. gateway
With durability: ack-on-write (the default), a power loss can drop records already acknowledged. Acknowledging before fsync is faster. Set durability: ack-on-fsync in production. doctor
A data directory on NFS, SMB, FUSE or similar can't be trusted for locking and fsync. Those file systems don't give the guarantees the store needs. Put data_dir on a local disk (D-DATA-FS). doctor
Gemini CLI tool calls carry no call id; Tracekit pairs them by tool and arguments, first in first out. The harness doesn't send one. None needed; be aware when reading paired results. coding agents
Reasoning capture reads only Claude Code's transcript, and the v1 model proxy fronts only the Anthropic API. Other harnesses use other formats and APIs. Use the v2 gateway or OpenTelemetry for model-side cross-checks. coding agents
Tracekit doesn't see whether Claude Code's sandbox started for a command, what it blocked inside one beyond what Claude Code writes into the result, or the sandbox settings (they aren't signed into the run). The sandbox runs inside Claude Code's process and keeps no record; hooks see the call and its result. Set sandbox.failIfUnavailable: true so Claude Code never falls back to running unsandboxed; tracekit doctor warns when it isn't set (D-CLAUDE-SANDBOX). Claude Code's sandbox
Tracekit doesn't see whether Codex ran a command in its sandbox: a request to run outside it (sandbox_permissions: "require_escalated"), Codex's answer, or a command an execpolicy allow rule let run outside it. Codex's sandbox and approval settings aren't signed into the run, and no rule holds an escalation as TK-A003 holds Claude Code's unsandboxed retry. Codex's PreToolUse hook sends a shell call's command only; the sandbox and its approvals run inside Codex. Keep escalations with a person (approval_policy = "on-request", approvals_reviewer = "user"), or set approval_policy = "never" so Codex refuses them; keep allow rules narrow. The policy still decides every command before Codex's approval. Codex's sandbox
A process's CODEX_TOOL_CALL_ID names the Codex call that started it, the tool_call_id of that call's records, but the process and its children can change or remove it. Codex sets it as a label for correlation, not authorization. Use it to find the signed call behind an effect; read a missing or unknown id as unexplained, and a match as a pointer, not proof. Codex's sandbox
Gemini CLI's whole-CLI sandbox (tools.sandbox, GEMINI_SANDBOX, -s) runs without Tracekit: in a container the hooks tracekit init writes don't run and the session isn't recorded, with no gap; under Seatbelt the hook can't keep its state and blocks every call. Gemini runs the whole CLI, its hooks included, inside that sandbox: a container gets your user settings without their hooks, and Seatbelt lets a process write only the workspace, temporary and cache directories. Use Gemini's tool sandbox (security.toolSandboxing: true) instead. tracekit doctor warns when Gemini's settings turn the whole-CLI sandbox on (D-GEMINI-SANDBOX), but can't see GEMINI_SANDBOX or -s at launch. Gemini CLI's sandbox
Tracekit doesn't see sandbox permissions Gemini CLI grants outside a call's arguments (what its approval mode allows, an expansion Gemini proposes itself, one allowed for good), and Gemini's sandbox settings aren't signed into the run. Gemini's BeforeTool hook sends the call's arguments; the sandbox and its approvals run inside Gemini. Avoid YOLO mode and answer expansion requests once, not for good; TK-A004 holds each expansion a call's additional_permissions asks for. Gemini CLI's sandbox
tracekit doctor doesn't see Gemini CLI's folder trust: Gemini ignores an untrusted project's settings, so D-GEMINI-SANDBOX can read a project setting Gemini doesn't apply, or miss what Gemini does. Folder trust is Gemini's own state, kept outside the settings files doctor reads. Put the sandbox settings that matter in your user settings (~/.gemini/settings.json), which folder trust doesn't switch off. Gemini CLI's sandbox
tracekit srt-decider sees only requests to the hosts in its --allow-domain list; srt-proxy refuses the rest itself and Tracekit records nothing of them. srt-proxy's decider protocol has the decider name its hosts before the first request, and no entry matches every host. List every host the workload needs; a missing one shows in the workload as 403 with X-Deny-Reason: host_not_allowed. sandbox-runtime
A request tracekit srt-decider allowed is recorded as allowed, not as sent: Tracekit doesn't see the response, nor whether srt-proxy forwarded it (it still refuses a host that resolves to a private address, and an answer that comes after its 5 s wait). The protocol carries requests and verdicts, nothing after. Keep the signer close to the decider so answers come in time; route calls whose result matters through the tool executor, which records it. sandbox-runtime
An ask through tracekit srt-decider refuses the request at once (403); the request is allowed only when the workload sends it again after a person approved it. srt-proxy waits 5 s for each answer. Have the workload retry a 403 that names an approval, or send the request again yourself once it is approved. sandbox-runtime
A tracekit srt-decider approval binds the method, URL and body digest: not the headers, nor the body of a GET, HEAD or OPTIONS. A body over 32 MiB is refused. srt-proxy shows a body only when asked, up to 32 MiB, and not for those methods. Where a header or such a body changes what a request does, deny that host by policy rather than ask. sandbox-runtime
One tracekit srt-decider is one run, whatever process sent a request, and the approvals it waits on live in its memory: after a restart the same request asks again. When the signer closes the run (no request for idle_s, an hour by default), the decider refuses the next request and stops srt-proxy. srt-proxy's requests name no client; a closed run records nothing more. Run one srt-proxy and decider per agent run, under a supervisor that restarts it; after a restart, approve the new request. sandbox-runtime
tracekit srt-decider decides each request on its method, URL and the body's SHA-256 only: content rules never see the body, so a rule on what a request sends (a secret, a customer record) can't match it. Only the digest goes to the signer, so the run never holds the body; the digest binds an approval to it. Decide such hosts by host and method; send calls whose content matters through the tool executor or the MCP gateway, whose policy sees their arguments. sandbox-runtime
(1.2) A run is recorded as a child (register_run parent) only when the caller presents the parent's run token as the identity it was issued to, on the parent's signer, while the parent is not final. The token proves the caller may act for the parent run; a final run takes nothing more. Start sub-agent runs from the code holding the parent's handle (client.run(..., parent=handle)), or with a delegate_run token for the sub-agent's identity, before the parent finishes. The refusal (run_token_invalid, unknown_run, run_closed) says which. format v2
v1 OTLP receiver and ingest gateway are experimental. They are being rebuilt; they run only with --experimental. Use the v2 signer's OTLP import. OpenTelemetry
v1 OTLP policy is evaluated after the fact: a call a rule would deny is recorded as flag, not stopped. Spans arrive when the work is done. Block with hooks, an SDK tool() or an adapter. OpenTelemetry
(1.2) An MCP call decided as mcp:<server>/<tool> reconciles with the model's tool use only when the model's name is the bare <tool>, mcp__<server>__<tool> (Claude Code) or mcp_<server>__<tool> (OpenAI Agents JS with includeServerInToolNames); its arguments are compared under that name. A framework that shows the server another way (mcp-agent's <server>_<tool>) gets args_mismatch for each call; with a bare name, two servers' same-named tools in one model turn are told apart only by call id. Only names that can't be a different tool's are accepted: a suffix match would let an agent rename delete_account to mcp:x/account. Expect args_mismatch for such frameworks until the policy can list their servers; keep the model's call ids (the shipped adapters do). MCP
(1.2) With a bare model name (OpenAI Agents JS, the Vercel AI SDK, the mcp Python client), nothing ties the server in mcp:<server>/<tool> to the model's call: an agent that decides the model's query as mcp:notes/query instead of mcp:postgres/query is reconciled, and rules keyed on the server (mcp:postgres/*) don't apply. A server or tool with an edge _ or a __, or a tool named like a prefixed form, gets no alias at all. The model never saw the server; only the agent's adapter names it. Key MCP rules on the tool name (it is also checked under its bare name) or use names that carry the server (includeServerInToolNames, Claude Code); put servers that need server-level rules behind the MCP gateway, which decides what it forwards. MCP, MCP gateway
(1.2) A long-running signer keeps a small stub of every run it has seen (about 1 KB in memory, 0.5 KB in each snapshot, plus the file store's per-record index), each final run's first late writers (up to 64), and every incident, resolved ones too (incident_list serves them); the request-id cache holds at most 100,000 answers (10,000 per identity). Snapshots are written whole on the writer, pausing writes for about 1 s per 300,000 runs. Final runs answer tokens, late writes and run_exists from memory; the stubs are the signer's index of what it has signed. Restart into a new store (a new data_dir, the old one kept for export and verification) once a store holds millions of runs; size memory at about 3 KB per run. deployment

Policy

Limit Why it is so What to do Details
An agent's edit of its own harness's settings or hooks (.claude/settings.json, ~/.gemini/settings.json, .codex/, ~/.cursor/hooks.json, by a file tool or a recognized shell write) is flagged (TK-F009), not held, and once it turns the sandbox off, TK-A003, TK-A004 and TK-A014 have nothing to hold: later commands run outside it with no escape-hatch argument. A write through a variable, a script or another program isn't matched. The rule covers every file under those directories (skills, commands, memory), which agents edit in normal work; holding all of them would ask about most sessions. Read TK-F009 flags as a sandbox change; keep the sandbox's settings where the agent can't write (managed settings, a read-only mount); add an ask rule of your own for the settings files your agents never need to touch. Claude Code's sandbox, Gemini CLI's sandbox
TK-A003 holds a call only when it carries dangerouslyDisableSandbox: true. Commands matching excludedCommands, hooks and MCP servers run outside the sandbox without it. Claude Code marks only the unsandboxed retry; the rest are outside the sandbox by configuration. Keep excludedCommands short; the policy still decides each excluded command and MCP tool call. Claude Code's sandbox
TK-A014 holds a call whose allowed_domains names a host; it doesn't see hosts opened by the sandbox's own settings (network.allowedDomains), and it can't tell which of the listed hosts a command then reached. The hook sees the call's arguments, not the sandbox's settings or its proxy's traffic. Keep the hosts a project always needs in network.allowedDomains (a person edits the settings) and approve the rest one command at a time. Claude Code's sandbox
The decide RPC answers allow for a flagged call; only the signed policy.decision says flag. decision tells the client what to do, and clients since RPC 15 know only allow, deny and ask; the signer can't tell a client's version. Read a flag from the record, or from the flag rule's id in the response's rule_ids. policy
Policy rules are tripwires that make attempts visible, not the protection itself. They match strings; a rewording a rule doesn't anticipate isn't matched. The protection is the signer's isolation, its storage and the witnesses. Rely on isolation and witnesses; read E3's misses. policy, evaluation
Shell variables are not expanded and encodings are not decoded. Static parsing can't know runtime values. Opaque commands ask (TK-SHELL-PARSE); keep them held. policy
A shell whose language the parser doesn't read (fish, csh, tcsh, PowerShell, nu, xonsh, elvish) asks (TK-SHELL-PARSE) whenever it runs commands from its line or stdin, even pwsh -c 'Get-Date'; a script file it runs (fish build.fish) isn't read, as a POSIX shell's isn't. Their syntax isn't POSIX, so the rules anchored on a command's name can't read what they run. Approve the held call, or run the command through a POSIX shell. policy
With on_parse_error: deny, an ordinary command whose program the parser can't read ($EDITOR notes.md, a script piped into bash, an unclosed quote) is denied, not held. The signer can't tell such a command from one built to hide what it runs, and with deny no person sees it. Keep the default (ask) wherever a person answers holds; with deny, have the agent name programs literally. policy
strict.yaml holds ordinary development work: every push, package install, docker run, network command and PATH=-style setting waits for a person (4.9% of E17's benign calls, 10.0% of E21's developer-session calls), and a command whose program comes from a variable ($EDITOR notes.md) is denied. It trades interruptions for seeing every change that leaves the workspace before it runs; the flags it raises can't tell a routine push from a harmful one. Use it where a person answers holds promptly; elsewhere use server.yaml and read the flags. policy
On calls written blind to the rules (E21's held-out pool), the server pack stopped or held half the harmful ones on its one blind run (211 of 420), and 237 (109 denied) after rules were added that target the pool's shapes, a number that overstates blind recall; it holds 18 of 432 benign ones. It misses most exfiltration (27 of 39), privilege-escalation (19 of 27), prompt-injection (14 of 22), destructive (21 of 36) and defence-evasion (16 of 30) calls, and half the supply-chain ones (13 of 27). The packs match known command shapes; calls phrased in ways no rule anticipates aren't matched. Treat the packs as tripwires: run agents in a sandbox, keep credentials out of their reach, rely on the signer and witnesses for the record; strict.yaml holds more (266 of 420) at more interruptions. evaluation
A rule's remedy is advice the policy's author wrote: lint checks its form (one short present-tense sentence), not that it is right, and an agent may ignore it. It is text shown with the decision, not a rule. Write a remedy that names a safe alternative the agent can carry out; the deny or hold stands either way. policy
A relative path, or one with .., is unverifiable; rules match its certain suffix. A symlink could redirect it. Write path rules that also work on suffixes. policy
Path rules, and shell rules on target and effects, are matched casefolded too (cp x .ENV is denied as .env is). The agent's file system may ignore case whatever the signer's does. Write path rules in lower case, or with both cases of a letter. policy
Patterns must stay inside an RE2 subset. A decision must not depend on which engine is installed. tracekit policy lint rejects the rest. policy
Without google-re2, a large subject, or a short one under a repeat of overlapping alternatives (secret(.|\s)*key), can exhaust the 1 s budget: the decision denies, is marked nondeterministic: true and says so in its reason, so the two engines can decide differently. Every shipped pattern searches 64 KiB built to make it back off or rescan within 150 ms on CI's runners (about 20 ms on a laptop), except eight on one command or SQL statement that repeats their first word thousands of times, measured at 64 KiB on an M-series Mac: TK-D002, TK-D004, TK-D006, TK-D008, TK-D011 and TK-DB003 over 5 s, TK-C010 3.4 s and TK-D007 0.3 s (curl with no pipe, rm -r with no root or home path, curl with no .env upload, find on a transcript path that deletes nothing, cat with no upload, DELETE FROM with no ;, aws s3 rm with no --recursive, kill with no tracekit after it): each occurrence rescans the rest, so such a call can deny on the fallback engine where RE2 decides it. The fallback regex engine backtracks; RE2 is linear. A rule that looks for one thing after another with any text between can't be written linearly for a backtracking engine without atomic groups, which RE2 lacks. Install google-re2 on the signer (a dependency from Python 3.10). policy
TK-F007 flags a detached tmux session only when its -d comes within 200 characters after tmux new, on either engine; one further along isn't flagged. The bound keeps the pattern linear on the fallback engine, and both engines run the same pattern so they decide alike. Read long tmux new commands in the record yourself, or add a rule of your own for the tmux options your agents use. policy
tracekit policy lint refuses an unbounded repeat whose body starts with an unbounded repeat ((a+)+), not every super-linear pattern: a bounded repeat of one (^(.*?,){11}P: 5 s on 260 characters) or overlapping alternatives under a repeat ((a|aa)+$: 3 s on 35) pass it. Refusing every repeat over an unbounded body would refuse linear patterns too (TK-M003's ([^"@]*@){10}); telling them apart needs to know whether the repeated class can match what follows it. Install google-re2; time a new pattern on the fallback engine before shipping it (the speed test in tests/test_policy2.py does this for the shipped packs). policy
A rule's subject over 64 KiB is never cut: the rule's own section decides. Cutting or windowing would let content hide past the cut. Expect large arguments to be flagged, held or denied by the matching section. policy
A decide whose parsed arguments nest deeper than a request may (128 levels, counting the request's own, or too deep to check) is refused as a whole (invalid_request), not decided: no policy.decision names the call, only the signer's refusal count does. Raw arguments (args_source: "raw") nested past 128 levels are denied and recorded (TK-ARGS-INVALID). Checking, hashing or copying such a request would exceed the parser's limit before anything could be recorded. Nothing to do for safety: the hooks and integrations don't run a call they couldn't get decided. Flatten arguments that deep if a tool really needs them. policy
URL rules read the URL text; a public name that resolves to an internal address isn't caught. The signer doesn't resolve names. Add network egress controls. Browser Use
In a shell command, a word that reads as an IPv4 address in any notation (2852039166, 169.16689662) is a target URL rules match, and so is a dotted number such as a version (npm version 1.2.3): TK-F012 flags it as egress. The signer can't tell a version from an address without knowing every command's arguments. Read egress flags on version-like words as noise; an allowlist of hosts goes in a rule's unless. policy
A browser rule on a click matches the page the click ran on, not the request it makes: navigating straight to the URL a denied button submits to, or running page JavaScript that submits the form, is another action. A click's arguments name an element, not where it leads. Deny the state-changing URL on navigations too (class: browser, field: url); keep TK-B012 holding evaluate. Browser Use
The server pack's allowlists (TK-N002, TK-B011) name example.com until you redefine them. Allowlists are deployment-specific. Redefine those rules by id in your policy. policy
Parsed arguments that hold a lone surrogate (text typed or read from a broken page) are a signed deny, TK-ARGS-INVALID, not a decision on their content. JSON can't carry a lone surrogate, so the client sends them as raw text, which isn't strict JSON. Clean the text before the call, or expect the deny. policy
Cloud-SDK-shaped tools other than CLIs and MCP tools aren't covered by the cloud pack. Their argument shapes vary. Map them under tools and add rules of your own. policy
(1.3) TK-A001 doesn't hold a saved destroy plan (terraform apply p), terragrunt, more than four options before the subcommand for terraform, fly and the npx-style launchers, an option value with a space in it, or a destroy run through an interpreter, a container or a remote shell. It reads each command's words as typed: a plan file's content, the file system's case and what another program runs aren't in them. Add ask rules of your own for the forms your agents use, and don't give an agent credentials that can destroy production. policy
TK-A002 doesn't hold a remote branch or tag deletion set through Git config or an alias (-c remote.origin.push=:b, -c alias.p=push), a refspec from a shell variable, brace expansion or xargs, a shell alias or function, more than eight global options before push, an option value with a space in it or git send-pack. It reads each command's words as typed; config, aliases and variables take effect only when git or the shell runs, and the bound on options before push keeps the fallback engine from backtracking. Use the server pack (its TK-S006 denies git -c alias.*) or run the agent in a sandbox, and protect the branches that matter on the Git host. policy
TK-D003 doesn't deny a force push set through Git config or an alias (-c remote.origin.mirror=true, -c alias.p='push -f'), a refspec from a shell variable or brace expansion, a shell alias or function, more than eight global options before push or git send-pack; and it denies a push whose quoted option value holds -f or +x (-o 'note -f'). It reads each command's words as typed, as TK-A002 does: values aren't expanded, and once the words are joined with spaces a value with a space in it can't be told from the words after it. Protect the branches that matter on the Git host so it refuses force pushes, and don't give an agent credentials that can rewrite shared history; the server pack's TK-S006 denies every -c alias.*. policy
Rules anchored on a command's name (TK-D001, TK-D003, TK-D004, TK-D010, TK-A001, TK-A002, TK-A011, TK-A013, TK-S006) read a wrapped command only through the wrappers and runners the parser knows (sudo, env, timeout, caffeinate, npx, uv run, direnv exec, mise exec, op run, …: shell parsing); another one (bwrap, whose options take two values) hides what it runs from them. Each wrapper's options and positional words differ; reading an unknown one's arguments as a command would misread ordinary ones. Run the agent where only the wrappers you use are installed, or add a deny rule of your own on the line (field: line) for the one you use. policy
TK-F014 (a trailing &, flagged as a background process) reads only the raw command line: it misses an & inside bash -c '…', eval, $(…), ssh host '…', a heredoc into a shell, an argv list or Codex's commands, a job followed by another command (sleep 9 & ls, or ls on the next line), a subshell ((sleep 9 &)) and sleep 9 & # bg; it still flags echo a\& and git commit -m 'x &; y'. In the normalised line a quoted & looks like a background job (echo 'x &'), so the rule keeps to the raw text, whose quoting it can't read. Read TK-F008 (the harness's own background flag) beside it, and run agents in a sandbox that limits what a background process can reach. policy
TK-D010 (writing a credentials file from the shell) also denies a read redirected from one (while read l; do …; done < .env), and TK-A011 and TK-A013 hold one from their places. Each rule reads every command and every redirection target on its own, anchored, so the fallback engine stays linear on long commands; a redirection's target doesn't say which way it goes. Read the file another way (cat .env, or the agent's file tool). policy
TK-A010 to TK-A013 hold only the writes they name: a file tool's path, or cp, install, mv, ln, tee, chmod (hooks), sed -i, dd of= or a redirection into the place. They don't hold another program writing there (an interpreter one-liner such as python -c or node -e, curl -o, rsync), a place named through a variable, a substitution or the working directory (cp x.plist $D/, "$(git rev-parse --git-dir)/hooks", cd ~/.config/systemd/user && cp a.service .), or hooks a hook manager installs (npx husky init, pre-commit install, a package's prepare script run by npm install). They read each command's words as typed; what a program writes, and what a variable or the working directory holds, is known only when it runs. Run the agent in a sandbox that can't write outside its workspace (Claude Code's, Gemini CLI's), and on Linux import a kernel sensor's records (Tetragon): each file write is signed into the run with its path, and tracekit analyze --v2 flags one no call explains (TK-X206). policy
TK-A011 and TK-A013 read a cp, install, mv or ln destination as its last word (or -t's): a copy out of a place to a name without a / (cp ~/Library/LaunchAgents/x.plist .) is held, and a destination path that leaves the place and comes back (.git/hooks/../hooks/pre-commit) isn't. The rule sees the command's words joined by spaces, so a destination name with a space in it can only be told from the words before it by the / it lacks; paths aren't resolved, and the anchored form keeps the fallback engine linear. Approve the held copy, or copy to a path with a directory (./x.plist); file tools' writes are read normalised (TK-A010, TK-A012). policy
TK-A010 and TK-A011 know launchd's and systemd's places, XDG autostart, /etc/init.d and /etc/rc.local, and turning a unit on with launchctl load, bootstrap or enable or systemctl enable, reenable, preset or add-wants. Other ways to run something at login or boot aren't held: cron (crontab is only flagged, by TK-F007; /etc/cron.d), shell startup files (flagged, by TK-F011), systemctl link, loginctl enable-linger, other supervisors (pm2 startup, supervisord, runit, OpenRC) and Windows' Run keys, Startup folder and schtasks. Each service manager keeps its own places and commands; the pack names those of the hosts coding agents run on most. Add ask rules of your own for the managers your hosts have, run the agent where it can't reach them (a sandbox, or a container without them), and on Linux import a kernel sensor's records (Tetragon): each file write is signed into the run with its path, and tracekit analyze --v2 flags one no call explains (TK-X206). policy
TK-A013 holds setting or unsetting git config core.hooksPath typed as a command (and git config --get core.hooksPath x, a read with a value pattern); it doesn't hold the hooks path set another way: git -c core.hooksPath=… <command> for one command, GIT_CONFIG_KEY_* environment variables, a .git/config a file tool writes, or a config file include pulls in. Git reads its configuration from several places when it runs; the rule reads one command's words. Use the server pack (its TK-S006 denies git -c with a hooks path), add an ask rule of your own for file-tool writes to .git/config, and review a repository's hooks path (git config --show-origin core.hooksPath) before anyone commits in it. policy
TK-A020 and TK-A021 recognize named keychain, password-store, browser-profile and SSH-agent access forms; TK-D020 blocks only known whole-store dump commands. Custom stores, SDK access, unusual paths and other export commands aren't recognized. The policy reads file paths and shell text, not the process's actual file or agent-socket access. Keep credential-store permissions narrow and use OS-level monitoring or isolation for broader coverage. policy
TK-A022 and TK-A023 cover writes to Docker daemon configuration/socket paths and direct docker run or podman run flags for privileged mode, host PID space or root mounts. docker container run and mounts of the Docker socket are not covered; compose contents, other runtimes, remote daemons and API calls aren't inspected either. File policy sees the path, not file contents; shell policy sees only command arguments. Review compose changes and remote/container API access separately, or use a runtime policy that sees the resolved container configuration. policy
TK-D021 blocks direct HTTP(S) package URLs in named install commands; TK-A028 asks on version-control URLs. npm i <url>, pip install -q <url>, pip install -U <url>, other option orders, preconfigured unknown sources, transitive dependency URLs and other package clients may not match. TK-A024 and TK-A030 hold a file tool's or a shell command's write to known source-config paths (.npmrc, pip.conf, .pypirc, apt sources, yum repos), and TK-A025 installs with explicit non-default index, registry or --find-links options (pip, uv, uvx, npm, npx, pnpm, yarn, bun, poetry, cargo, gem), config set (npm set) of the index or registry, and the source variables pip, uv and npm read set before a command or through env or export (PIP_INDEX_URL, UV_INDEX_URL, npm_config_registry, …). An index named inside a requirements or lock file (pip install -r of a file with --index-url), a config file chosen by a variable (PIP_CONFIG_FILE, NPM_CONFIG_USERCONFIG), a config file written by an interpreter or under another name, and other package managers (conda, poetry source add) aren't held. The signer doesn't resolve package-manager configuration or dependency metadata before deciding, and reads files only by the names a command gives them. Pin and verify package sources in a controlled build environment. policy
TK-A026 and TK-A027 hold edits and recognized shell writes/deletes (effects: a redirection, rm, mv, cp, ln, tee, truncate, shred, dd, sed -i, perl -i, find -delete) to named history and log paths, and TK-A029 history -c, turning history off and vacuuming the journal. Other history-file writes such as history -w, application-managed history, remote logs and paths reached through an unrecognized program (python -c) may not match. The rules match visible paths and command arguments; they don't observe filesystem effects. Preserve logs outside the agent's control and use a sandbox or kernel sensor to detect writes. policy
TK-A027 holds find … -delete and find … -exec rm (or unlink, shred, truncate) on history and log paths, not a delete made by a shell find runs (find /var/log -exec sh -c 'rm "$1"' _ {} \;): the paths reach that rm only as $1. Variables aren't expanded, so the rule can't tell which files the inner shell is given. Add an ask rule of your own for find with -exec sh on the paths that matter, or keep logs outside the agent's reach. policy
TK-A024 and TK-A025 read npm's and pip's ways of setting a package source; Yarn Berry's (yarn config set npmRegistryServer <url>, or .yarnrc.yml written by a file tool) isn't held. Each package manager names its registry setting differently, and the rules name npm's, pip's, apt's and yum's. If your agents use Yarn 2 or later, add ask rules of your own for yarn config set npmRegistryServer and writes to .yarnrc.yml. policy
The payment class doesn't extract new_payee yet. Not built. Match payees with your own rules. policy
unless on a whole command line could exempt a different target. The subject holds several targets. Use unless only where the subject is one target. policy
v1 rules (tracekit/policy/default.yaml) match raw strings, so quoted text that mentions a risky command can trip them. The v1 engine doesn't parse commands. Use the v2 signer's policy; add exceptions for known false positives. evaluation

Provenance (1.1)

Limit Why it is so What to do Details
(1.1) Provenance matches values verbatim: a value the model paraphrases, splits or re-encodes reads as a new value. It is a tripwire on the commonest way injected instructions act, not information-flow control. To prevent the flow, use information-flow control (CaMeL, FIDES) in the agent. policy
(1.1) Only untrusted tools' results and observed inputs are indexed: a page fetched with a tool the policy doesn't list as untrusted (curl in a shell) isn't seen. Results of other tools can echo the agent's own arguments, so they aren't counted either way. List such tools under untrusted, or observe the content with run.observe. policy
(1.1) Only an input observed as trusted makes a value trusted: a value from your own data that also appears in untrusted content (a customer's address from the CRM that is also in their ticket) is held. Counting other tool output as trusted could launder an injected value. Observe the record you looked up as trusted, or answer the hold. policy
(1.1) Whenever a run's index is not complete, from: untrusted rules fail closed: they match whenever their pattern does (the packs' TK-P rules ask). It is unavailable after a signer restart, truncated once a result carries more than 4096 values (or 1 MiB of text) or the run reaches 20,000, partial once an untrusted tool completes with a digest or a client's events skip a client_seq (an observe or a result lost in a signer outage), and timeout for a decision whose values took past its 1 s deadline. The index lives in memory only and is bounded; a value it can't vouch for may be injected, and a lost event may have carried one. Watch provenance_state and provenance_reason on decisions; split very long runs; expect more holds after a restart or an outage, and start the task again in a new run to end them. policy
(1.1) Provenance needs the result itself: for a tool the policy lists as untrusted, decide answers send_result and the shipped adapters (Python, TypeScript, the Claude Code hook) send the result instead of its digest. A result over 512 KiB, or an adapter of your own that sends a digest, makes the run partial. The signer can only index content it receives; it redacts and commits to it and keeps nothing in clear. In your own adapter, send result itself when the decision says send_result. policy, privacy
(1.1) trust: "trusted" on observe is the calling process's claim. Provenance protects against injected content, not against a malicious agent program, which can observe anything as trusted (or send digests) and get past from rules. The signer can't tell who wrote content the caller supplies. Observe the task from your application, not from the agent; use the other rules for a hostile agent. policy
(1.1) Bare IPv6 addresses and integer IPs in text are not read as hosts (inside a URL they are). Bare forms are ambiguous in prose. Rely on the URL forms, or add rules of your own. policy
A Browser Use navigation with a query string sends data to its host, so TK-P003 holds one to a host that only a page named (a search result's link with ?id=5 included) unless the task named it. Data in a URL's path or fragment isn't counted as sent. A query string is how a GET carries data to an attacker's host; telling an ID from a card number takes reading the value. Observe the task as trusted; to hold only typing and uploads, redefine TK-P003 with `tool: '^browser_use:(input upload_file)$'`.
The Browser Use adapter observes what the model read of each page (URL, title, tabs, text) up to 100,000 characters, from each browser state the session's get_browser_state_summary returns, which it wraps at the session's first action; the first step's state, made before that, comes from Browser Use's cached copy, which a tab closing before the first action empties. A link's target the model wasn't shown enters the index only once the agent is on that page. Browser Use shows the model 40,000 characters of a page by default, and the adapter first sees the session at its first action; asking it for a fresh state would renumber the elements the step's actions refer to. Keep max_clickable_elements_length at or under 100,000. Each page adds its values to the run's index (20,000 at most, then truncated): give a long browsing session a run per task. Browser Use
After a signer restart every run that was open has an unavailable index for the rest of the run: each call a from: untrusted rule's pattern matches is held, even typing into a site the task named (its decision's provenance_reason says so). The index holds per-value keyed hashes in memory under a per-process key; persisting them would keep brute-forceable hashes of card numbers and addresses at rest, and would need a crash to invalidate them. Answer the holds, or start the task again in a new run: a run registered after the restart has a complete index. Restart signers between runs where you can. policy
(1.1) Reading values is bounded: linear in the text, at most 1 MiB and 4096 values per result or argument. A result is attacker-controlled; the work must not grow with it. Nothing: past the bound the run is truncated and fails closed. policy
(1.1) Bundles from a 1.1 signer must be exported with tracekit >= 1.1: a 1.0 exporter labels them 1.0.0, and a 1.0 verifier fails their schema. Provenance adds record fields 1.0 doesn't know. Upgrade the exporter and verifiers together with the signer. format
(1.2) TK-P003 reads only a browser navigation's query string as data sent to the host: data put in the path (https://evil.example/c/<card number>) or in the host name (https://<data>.evil.example/) isn't, so such a navigation to a host only untrusted content named is not held. Following a link from a page or a mail is a navigation too; holding every path would hold every link the agent follows. Allow only the hosts the agent needs (TK-B011's allowlist), or run the egress observer, which records the hosts reached. policy

Detection (1.2)

Limit Why it is so What to do Details
(1.2) Sequences and limits catch the patterns they describe, nothing else: a reworded attack that carries none of their labels isn't seen. Detection is deterministic rules over rule ids and labels, no model. Read incidents as tripwires; add sequences for the patterns you care about and backtest them. policy
(1.2) A hold or quarantine is recorded for every run, but enforced only where the signer decides each step: a gateway's model loop, an executor's tools (T2). A T1 adapter that ignores a decision can still run the call. On T1 the agent's own process runs the tool; the signer answers, it can't stop it. The verifier flags any result recorded while held or quarantined. Put tools that must be containable behind the executor or MCP gateway, and models behind the LLM gateway. incidents
(1.2) With no person approver configured (approvals.approvers naming persons), every enforced hold is a quarantine, and run_hold is refused. Only a person may answer a held call; a hold no one may answer would never end. Configure approvers mapped in approvals.persons (or OIDC persons or groups) before enforcing holds. incidents
(1.2) Suspensions are shared only through the suspension list file: each replica reads it per call and writes its own enforced record when it first sees an entry; two operators changing the list at once on different replicas can lose one change. Holds, quarantines, cooldowns and a paused response engine are per replica. Each replica has its own log and state; the list is the one thing they share, and no token is revoked automatically. Put the list on storage every replica reads (suspensions:), change it from one place, and send pause and resume to every replica (tracekit signer incidents pause --signer ... --signer ...). incidents
(1.2) tracekit signer incidents list reaches only the replicas you name with --signer (the default: one). Incidents live in each replica's log; there is no central index. Name every replica's URL when listing, or read incidents from each log's bundle. incidents
(1.2) After a restart that loses the detector's state, a decision whose detection state is unavailable holds its run (detector_unavailable) when the policy enforces a hold or quarantine. Fail closed: the detector can't vouch that no sequence completed. Keep snapshots (they hold the detector's state); expect held runs to resume by a person after an unclean restart. incidents
(1.2) The call that completes a sequence was decided allow and counted before the hold: it is decided again, held, so it doesn't run on that decision; a call consumed by a person's approval as it fires a hold stays in flight. Detection reads the signed decision, so the decision exists before the incident. Read in_flight on incident.opened: those calls may still complete. incidents
(1.2) The verifier recomputes only incidents of scope run whose cites are in their run, under the one policy that run was decided by; session, owner and principal incidents, operator incidents and runs decided under several policies are counted signer-asserted. A bundle holds one run's records; other runs' records and earlier policies aren't in it. Export with the signer's config so its policies are bundled; export the run-set to see the other runs of a scope. incidents
(1.2) Incident webhooks are delivered at least once (a receiver may see one twice) and a 4xx-refusing endpoint is skipped. A new webhook starts at the log's end, not its beginning; one whose saved cursor is unreadable sends every incident record of the log again. The outbox is a cursor over the log that moves only once the endpoint took a batch; a refusal won't change by resending. Deduplicate by incident id and record uid; fix the endpoint and read missed incidents from the log or tracekit signer incidents list. webhooks
(1.2) No trigger suspends an identity: respond holds or quarantines runs, and only an operator suspends (lint refuses suspend: in respond). A run's owner is often an identity every conversation of a server shares, and content the agent reads can steer every trigger, so an automatic suspension could stop every user's agent. Alert on incidents (action: notify) and suspend with tracekit signer incidents suspend after a look. incidents
(1.2) The signer refuses to start when one authorize entry grants both register_run and a containment operator call. An agent's identity must not resolve, resume or pause its own containment. Give operators an identity of their own (a person's OIDC login or a separate uid or token). incidents
(1.2) session scope joins runs only through parent_run: a sequence split across runs registered without parent is two sessions. The signer joins runs only on a link it can check (the parent's run token). Register subagent runs with parent; use owner scope to join all runs of one identity. policy
(1.2) Windows use the signer's clock, and are fixed: an NTP step moves them, and a burst straddling two windows can reach up to twice max before a limit fires. Records' ts is the only clock a verifier can recompute; fixed windows keep the work O(1) per call. Keep the signer on NTP; set max at half the burst you must catch. policy
(1.2) A sum limit holds a call of a known class it counts without a non-negative integer amount and a three-letter currency code (ask, TK-LIMIT-SUM), unless its respond mode is off; approved, or of an unknown class, such a call isn't added to the total. A total across currencies, of negative amounts or of an unreadable amount would mean nothing; an unmapped tool's arguments needn't be a payment's. Send amounts as non-negative integers in minor units with their currency code; map payment tools to a class; approve held calls knowing the limit doesn't see them. policy
(1.2) Decisions say detect.state: unavailable, and steps and counts from before are missed, after a restart from a snapshot written without detection or under another policy, after a replay of decisions made under another policy, and for a run the detector never saw registered. What came before such a snapshot is no longer read, and another policy's decisions carry its rule ids, not this one's. Change a detection policy when runs are quiet; expect missed steps and counts until the longest window has passed after the restart or the last such decision, and backtest the store for them. policy
(1.2) A scope with no record for an hour (or the policy's longest window, if longer) starts over, open incidents included: a later match opens a new one. State must not grow with every run and owner the signer has seen, and the reset follows the records' ts, so a backtest or verifier gets the same incidents. Group incidents by trigger and scope when you read them. policy
(1.2) Only opened incidents count toward the caps: past 100 of one trigger in a run, 20 from one run in a minute, or 60 in a tenant in a minute, one incident.capped is written and the rest are not. An open incident is updated at most once per window of its trigger (a minute for one without). A loop must not flood the log with incidents, and one run or trigger must not use up another's. The share is per run, so an identity with several runs can use up more of the tenant's 60, and another agent's incident that minute is only incident.capped. Read the run's decisions (their detect field) or backtest the policy to see the rest; keep open_runs quotas tight for agent identities. policy
(1.2) The server pack's limits were checked on small shipped runs only, and TK-Q001's window was not backtested. Those are the runs that ship, and none of them reads a secret or sends anything. Run the pack in shadow, backtest your own store (tracekit policy backtest) and set max above your busiest minute. policy
(1.2) One call whose own names match consecutive steps takes them all, each name once (a command that reads a secret and sends it completes [secrets_access, egress]); a step matched only through * or an unknown class takes a call one step. An unmapped tool matches every step; letting it chain would complete any sequence in one call. Give each step its own label; a sequence that repeats a label needs that many calls. policy
(1.2) egress (TK-F012, TK-F013) is a URL a call acts on or a network command; secrets_access (TK-F005) is a call that acts on a secrets file's name, writes included (a lone env template path, such as .env.example, excepted). Mail, payments, SQL, a program that opens its own connection, and content that holds a secret without such a name are neither. The labels read what the signer can see in the arguments, deterministically. Label your own tools that send data or read secrets (label: egress / secrets_access on a flag rule) to bring them into TK-Q001. policy
(1.2) A step counts when its call is decided (allow, flag) or its approval consumed, whether or not the call then runs; a call that runs after a deny or an unapproved ask never counts as a step. Detection reads decisions, which the signer has before a call runs; a call run against its decision is executed_against_policy. Keep the server pack's executed_against_policy trigger on (shadow, notify), and read those incidents alongside sequences. policy
(1.2) A decision retried under a new request_id is a new decision and counts again. Each decide writes its own signed decision; remembering every counted call would grow the detector's state with calls. It can only over-count. Retry with the same request_id (the signer answers it from its cache); allow for client retries when you set max. policy
(1.2) An incident of a session, owner or principal scope is opened in the run whose record fired it; its incident.updated records are written in the runs whose records matched later. Every incident record is written in the run of the record that fired it, so it lands in that run's hash chain. Follow an incident by its incident_id across the runs of its scope. policy
(1.2) backtest reads the rule ids decisions recorded: for decisions made under another policy, rules and labels the new one adds or changes are not applied. Arguments are not stored, so a decision can't be made again. Heed its warning (the count of such decisions); backtest changes to sequences, limits and respond, and measure new rules by running the policy in shadow. policy
(1.2) A bundle with detect fields or incident records needs a 1.2 verifier. They are format 1.2 additions. Upgrade verifiers with the signer. format

Content vault (1.1)

Limit Why it is so What to do Details
(1.1) With content.retain on, whoever holds keys/content.key and the store can read retained content. The signer must decrypt it to answer content_get. Keep the keys dir to the signer's user (D-CONTENT-VAULT), keep retention short, erase runs you no longer need. privacy
(1.1) The content master key is a file key (keys/content.key), not wrapped by KMS, even when the log key is in AWS KMS. The KMS log key is an Ed25519 signing key, which can't wrap keys. Protect the data volume as you do the other file keys; back the key up with them. deploy
(1.1) keys/content.key has no rotation path: replacing it makes every run already in the vault unreadable (content_get answers unavailable for them). Each run's key is wrapped by the master key, and nothing re-wraps them. Keep the key for as long as you keep content; replace it only to give up everything the vault holds. deploy
(1.1) Erasure (and retention) reaches the live store only: a backup, WAL archive or replica taken earlier still holds the run's wrapped key and blobs, and opens with keys/content.key. The signer can't delete copies it doesn't hold. Keep backups and archives shorter than retention_days, or rotate content.key to cut off older copies, which today also makes every run still in the vault unreadable (no re-wrapping yet). privacy
(1.1) A decision's arguments are kept redacted: if redaction changed them, they no longer open args_commitment (content_get marks them redacted, without a salt). The same holds for a model exchange redaction changed whose tool uses carry args_commitment: no salt, so its content_digest can't be checked either. Nothing unredacted is written; the policy decided on the unredacted arguments. Use the tool result and the model exchange, which open their commitments, for what the call did. privacy
(1.1) Content over max_record_bytes isn't kept (too_large), and one content_get answer must fit the 1 MiB frame, so an item near 1 MiB can't be read. The RPC answers in one frame. Ask for fewer seqs at a time (the refusal says so); keep max_record_bytes well under 1 MiB if you need every item back. privacy
(1.1) The gateway asks the signer once whether the vault is on, needs the status grant, and sends no body over half a frame (not_retained). One status call per gateway process; the frame bounds a request. Grant the gateway status; restart it after turning the vault on or off. gateway
(1.1) Erasing a run erases the replay runs (tracekit why) the vault holds for it when it runs; a replay run that is still being written as the run is erased can keep what it records after that. Finding them reads the first record of every run with content in the vault. Replay runs copy the run's content into their own requests; the store does not index runs by what they replay. Erase a run once no investigation of it is running. privacy
(1.2.2) The dev signer keeps each call's redacted arguments and results for 7 days in its data dir, readable by your user, so the dev viewer can show them. The viewer needs the content, and the dev signer runs as you: you hold every dev key already. TRACEKIT_DEV_CONTENT=0 before the dev signer starts keeps nothing (the viewer then shows rule reasons only). privacy
(1.1) Retention counts from the first item kept for a run, not per record. A run's items share one key, erased at once. Pick retention_days for your longest runs. privacy
(1.1) content_get and content_erase reach runs of the caller's own tenant only. The tenant comes from the caller's identity. Give each tenant's investigator its own identity. privacy
(1.1) On an existing Postgres database, tracekit signer migrate adds the vault's tables but grants the signer's role nothing on them: until you do, items read back not_retained. migrate runs the schema only; grants are applied by the init script or by hand. Apply tracekit.storage.postgres.GRANTS to the signer's role after migrating (the signer's log line says so). deploy
(1.1) An item is stored just after its record: a crash or storage error in between leaves it not_retained, and a model body that isn't strict JSON (an integer past 2^53, a non-finite number, a lone surrogate) is recorded without it (the gateway sends none: not_retained). The record is never held back for its content. Treat not_retained as missing evidence, not as an empty value. privacy

Approvals

Limit Why it is so What to do Details
On the dev signer (no signer.yaml) the requester can answer its own approval. A signer started from signer.yaml refuses that unless approvals: {self_approval: allow}, and without an approvals section has no approvers at all. The dev signer has no separate approver. Configure approvals: {approvers: [...]}; self-approved answers cap assurance at dev and show approvals: self. approvals
A T1 approval binds the arguments the agent reported, and the agent's process runs the call. The adapter runs in the agent. Use approval: {executor: t2} and route the call through the tool executor or the MCP gateway, which run the approved arguments outside the agent. Adapters run the approved arguments too, but in the agent's process. approvals
Approvers answering through the web viewer or Slack are vouched for by the bridge (attested: false). The signer authenticates the bridge, not the person. Grant approval_decide_on_behalf only to bridges you run; read via on each approval. approvals, approvals
The signer takes the bridge's word for who registers a passkey, and keeps a person's first passkey. It has no proof of its own of the person behind a session. Have each approver register right after onboarding; check passkeys.json against the approver list. approvals
Break-glass answers aren't available through Slack. Break-glass needs a reason and a direct identity. Answer break-glass approvals from the CLI. approvals
After a restart the Slack bridge posts the approvals still pending again. It keeps which messages it posted in memory. Expect duplicate messages after a restart. approvals
An approval expires one hour after it was requested. Nothing should consume a stale approval. Answer within the hour, or request again. approvals
An MCP call is named by the server's own name (mcp:<server>/<tool>); an mcp 1.x session whose initialize() ran before tracekit.instrument(), or one wrapped by hand without server=, is recorded as mcp:unknown/<tool>, with a warning. The name comes from the server's initialize result, which an mcp 1.x session doesn't keep. Call tracekit.instrument() before any session initializes, or pass TracekitSession(..., server="<name>"); write rules for server-named tools knowing the server picks its name. MCP
An adapter without saved state (MCP, Browser Use, the Claude Agent SDK hook) holds a call while waiting; the wait doesn't survive the process, and a long wait ends in a denial. There is no resumable state to carry the approval. Answer within the adapter's wait; use a framework with saved state for long waits. MCP, Claude Agent SDK
LangGraph: code that catches exceptions around a gated ToolNode (a try/except Exception in a node that invokes it, a middleware outside TracekitMiddleware that turns exceptions into messages) ends an ask's pause: the model gets an error, the call doesn't run, and its approval expires. The node's own handle_tool_errors and .with_fallbacks() let the pause through. LangGraph pauses by raising GraphInterrupt out of the node; code that catches it ends the pause before LangGraph sees it. Re-raise langgraph.errors.GraphBubbleUp before any broad except, or put the error handling in the ToolNode (handle_tool_errors, .with_fallbacks()). LangGraph
A Browser Use action waits for an approval at most five sixths of Browser Use's action timeout (150 s of 180 s); then the approval is withdrawn and the action refused. Browser Use cancels an action that outlives its timeout, approval wait included, with a misleading "dead CDP WebSocket" error. Answer within the wait, or raise BROWSER_USE_ACTION_TIMEOUT_S (and the Agent's step_timeout) to give people longer. Browser Use
OpenAI Agents' LocalShellTool and ApplyPatchTool can't wait for an approval: an ask for them is refused. The SDK gives no approval hook for them. Allow or deny them by policy. OpenAI Agents
v1 approvals name the held call, not its exact arguments. v1 approvals predate argument binding. Use the v2 signer, which binds approvals to the exact arguments. laptop threat model
(1.2) The agent-approvals kill switch (approval_agents) is signer-wide: switching it off stops and revokes agent approvals for every tenant. It protects the signer from a misbehaving reviewer, which may serve several tenants. Grant approval_agents only to the signer's operators, not to one tenant's. approvals
(1.2) An agent approver's bounds bind the approval, not the execution, unless the rule has executor: t2. Under T1 the agent's own process runs the call with the arguments it holds. Put executor: t2 on agent rules, above all on money-moving ones (tracekit policy lint warns), and run held calls in a worker that executes what approval_consume returns. approvals
(1.2) A reviewer agent's judgment is model output: within the bounds, the requesting agent can steer it. It reads arguments the requesting agent chose. Set bounds you would accept every call inside of; start in mode: shadow and compare its suggestions with your people's answers. approvals
(1.2) The untrusted-value floor relies on an honest adapter's trust labels: content the adapter reports as trusted (or never reports) does not send a call to people. observe's trust is the caller's claim, and only untrusted tools' results are indexed. List every tool whose results come from outside in the policy's untrusted, and observe outside content as untrusted. policy
(1.2) Budgets and review caps are counted per signer: replicas that each write their own log multiply them. Each signer counts the agent approvals in its own records. Divide budgets by the number of replicas, or route a tenant's runs to one replica (route). approvals
(1.2) An agent approval under a sum budget records the value it took (spent) in the clear. The budget is rebuilt from the signed records, which otherwise hold commitments only. Use sum on amounts you are fine to see in a bundle; use count alone otherwise. approvals
(1.2) Anti-shopping matches the same arguments or the same tool call id: a call with one argument changed is a new call an agent may answer. Any other match would need a notion of "near" arguments. Rely on the bounds and budgets; a run gets at most 10 agent reviews and a tenant 1000 an hour, then people answer. approvals
(1.2) After a signer restart, calls of runs registered before it go to people. The provenance index lives in memory, so their provenance is unavailable. Expect more person answers right after a restart; new runs are not affected. approvals
(1.2) The verifiers can't check an agent approval's argument bounds. The records hold the arguments only as salted commitments. Read the approvals by agents line as "bounds the signer checked"; to audit one, tracekit signer reveal --record <seq> and recompute its commitment from the arguments. verdicts, privacy
(1.2) Without the policy snapshot of the decision's policy_hash in the bundle, the verifiers don't check that the rules permitted agents (a warning: agent approval bounds unchecked). tracekit export --v2 bundles the policy an agent approval's decision cites from the signer's <data_dir>/policies, where the signer writes each policy it starts with; an export with another data dir (another replica, a copied store) or after that file was removed has none. Export with the signer's config; keep <data_dir>/policies with the store, or check the old policy file's hash by hand. verdicts
(1.2) An approval an agent's answer can decide (enforce mode) reaches people (Slack, the desk's "for people") only once it is escalated: with no agent answering, after agent_timeout_s (default 600 s). The signer gives the agent approver that long to answer first. Keep the reviewer running on every replica; lower approvals.agent_timeout_s for faster handover; approval_agents {enabled: false} hands every pending one to people at once. approvals
(1.2) The reference reviewer sends the redacted arguments of the tenants it serves to a model provider. Judging a call means reading it. List in its tenants only tenants that accept this; see privacy. approvals
(1.2) The reference reviewer keeps which approvals it answered in memory: after a restart it answers a shadow-mode approval still pending again (a second approval.suggested). Shadow answers leave the approval listed for agents. Count suggestions per approval id when comparing; enforce-mode answers are not affected (the signer refuses a second one). approvals
(1.2) tracekit reviewer eval judges views without arguments. Past arguments are in no record (salted commitments) and the signer deletes its copy once an approval ends. Treat eval as a first check of the prompt and the rules; measure on live traffic with mode: shadow, where the view is complete. approvals
(1.2) Each review registers a run of its own on the signer (the reviewer's run identity), one per approval. The model call is evidence too, linked to the approval it judged. Size the signer's run quotas for one extra run per agent-answered approval. approvals
(1.2) An agent's approval runs only within agent_consume_s (and after veto_s); an adapter that waits longer gets TK-APPROVAL-EXPIRED. An unconsumed agent approval should not wait for a better moment. Consume right after approval; raise approvals.agent_consume_s for slow adapters. approvals
(1.2) The verifiers check who answered an approval and that an agent's answer stays within its bounds, not the order of the approval's records: an agent answer after approval.expired or after another agent's rejection, a consume after approval.revoked, or a consume outside not_before/consume_by still verifies. The signer enforces each of these when it happens; the offline check of their order is not built yet. Read an approval's records in order (tracekit approvals show, the viewer) when a veto or an expiry matters. approvals

Custody (1.2)

Limit Why it is so What to do Details
(1.2) An approval the executor or MCP gateway stopped waiting for stays requested until it expires (an hour): a person may still approve it, and it runs only if the agent sends the same tool_call_id again. The gateway holds the call, and approval_abandon is the agent's call, which may not touch a held one. Reject approvals whose caller has given up (the viewer shows the request's age); keep approvals.ttl_s short where agents retry by id. executor
Custody covers only the actions routed through the tool executor, the MCP gateway and the LLM gateway. An agent that holds a credential, or can reach an API itself, can act around them. Keep the credentials only in those services and fence the agents' egress (agentEgress in the central chart). executor, MCP gateway, Kubernetes
The agents' egress NetworkPolicy is off by default, and enforced only by a CNI that enforces egress policies. Turning it on cuts every other connection the agents make; some CNIs ignore egress rules. Set agentEgress.enabled once the agents need nothing else, on a CNI that enforces egress (Calico, Cilium, ...); test it from an agent pod. Kubernetes
The executor and the MCP gateway fail closed: with the signer unreachable nothing runs, whatever the run's fail modes. They exist to make an action impossible without a record. Keep the signer available; the agent gets 503 or an isError result saying so. executor, MCP gateway
A result the executor or the MCP gateway can't record after the call ran still goes to the agent. Withholding it would make the agent retry an action that already happened. They try twice, then say on stderr which call and run; keep the signer reachable. A run that closes with such a call still out waits up to gateway_wait_s (default 600 s) past its grace window for the result; then the signer writes a held_call_unresolved gap for the call and the verifier warns (held calls). executor
A run with a call the executor or the MCP gateway is still running reaches run.final up to gateway_wait_s (signer config, default 600 s, the MCP gateway's upstream timeout) after it closes. The service may still record the call's result, which a final run would refuse. Lower gateway_wait_s to the longest upstream timeout of your gateways if runs must finish sooner. executor
The events a gateway (the executor, the MCP gateway, the LLM gateway, the OpenShell middleware, the ext_mcp processor) writes for an agent count against the gateway's rate for that agent (limits.events_per_s, burst), its approval waits against its concurrent_waits for that agent, and the runs it registers for one (the OpenShell middleware's, one per sandbox) against its open_runs for that one; the result of a call it decided counts against nothing. A gateway's total is the per-agent limit times the agents it serves, but for open runs at most limits.gateway_open_runs (10,000) of its own and those it registered for all its agents together (1.4); a suspended agent gets no run through a gateway either. So an agent can't spend the budget its service's records need, nor one agent what the service's other agents need, and a call's result is never refused for rate once its decision was taken. The agent a gateway names is its word, so a cap of its own keeps a gateway naming made-up agents from opening runs without end. Size limits for one agent's traffic through a gateway, and gateway_open_runs for the runs one gateway keeps open at once; list only gateways you run under gateways. executor
A call the executor or the MCP gateway decided is theirs, for the identity they decided it for: the agent's own decide, approval_request, approval_consume, approval_abandon and complete on its tool_call_id, or the service's for another identity on the run, are refused (forbidden), and the executor refuses a tool_call_id the agent decided itself. An approval of such a call can't be abandoned; it expires. Otherwise the agent, holding its run token, could record a forged result or ask for the approval in its own words for a call made with credentials it doesn't hold. Leave an unanswered approval to expire. The adapters leave the tools the executor helpers build to the executor (the Claude Agent SDK hooks: the tools of the MCP server named tracekit-executor); keep a tool you build yourself that calls the executor out of the adapters, or in a Claude Agent SDK server of that name. executor, approvals
The executor takes only letters, digits and . _ ~ - (not all dots) in an {arg} of an action's URL path. A slash, backslash or dot segment, even percent-encoded, can leave its segment once a proxy decodes the path. Put other values in the query (?q={q}, quoted whole) or the JSON body. executor
The MCP gateway's trusted makes each resource of those upstreams count as trusted content, which provenance doesn't hold calls for. Prompts are always recorded untrusted. A resource anyone can write (an issue, a page, a mail) can carry an injected value; a prompt template echoes the agent's arguments. List in trusted only upstreams whose content neither the agent nor outsiders can write. MCP gateway
A stdio MCP upstream runs as the MCP gateway's uid, so it can read every other upstream's secret files and the gateway's signer credential. The gateway starts it as a child process with its own permissions. Run one gateway (its own identity and secrets) per stdio upstream you don't trust fully; pin each command to an exact version (npx -y pkg@1.2.3) or install it in the image. MCP gateway
A gateway's or executor's call for an agent is authorized as the agent's scheme:subject alone: authorize entries for an OIDC person or group don't match it, and harness binding doesn't apply to it. The service names the agent by caller, which carries no claims, and authenticates it itself, away from the agent's process. Grant agents that call through these services by scheme:subject in authorize; rely on the service's own authenticators, not harness binding, for who may call it. executor
The MCP gateway lists the first page of each upstream's tools, resources and prompts, reads only resources it listed, and relays no server-initiated messages. It answers each request with one JSON response and keeps no session. Keep upstreams' lists to one page; list resources before reading. MCP gateway
Credentials are cut only as configured: the executor and the MCP gateway replace each secret value of at least 8 characters (of a <scheme> <credential> value also the credential, of a Basic one its user:password and password) in the decoded string values they answer and record, in its JSON-escaped, percent-encoded and HTML-escaped forms too, but not a value shorter than that or one an upstream otherwise encodes or transforms (base64, a hash, another case, split across fields). A header value must be one line of printable ASCII. They match exact strings: a short value ("1", "info") would cut every answer. The line rule keeps a value out of the errors HTTP libraries raise. Use credentials of 8 characters or more and upstreams that don't echo them; put values that aren't secrets in settings, which are never cut. The services refuse to start on a header file that breaks the line rule and name the file. executor, MCP gateway
The MCP gateway neither lists nor calls an upstream tool whose name starts with prompt:. Prompts are decided as mcp:<upstream>/prompt:<name>, so such a tool would be decided under a prompt's name. Rename the tool in the upstream. MCP gateway
The LiteLLM guardrail decides only the MCP tool calls the LiteLLM proxy runs (pre_mcp_call). Tool calls the model returns through LiteLLM's chat, responses or messages endpoints, and MCP listings, resources and prompts, pass undecided. The agent runs the model's tool calls, so only its side can decide them and record their results; the guardrail sees MCP calls through LiteLLM's hooks. Gate the agent's own tools with a framework integration and record model exchanges with the LLM gateway; keep the MCP servers reachable only through LiteLLM. LiteLLM
The LiteLLM guardrail takes the agent's identity from tracekit_caller in the metadata of the LiteLLM key: whoever can edit a key's metadata decides which identity its requests act as. LiteLLM passes request headers, X-Tracekit-Run included, to its logging callbacks. LiteLLM authenticates the key, not the agent's Tracekit identity, and treats key metadata as admin configuration (its guardrail opt-outs live there too). Keep agents' keys away from LiteLLM's key management routes; a run token read from the logs works only for the identity it was issued to. LiteLLM
The LiteLLM guardrail records a result when LiteLLM logs the call: an MCP call answered with an interim input-required result, or one that raised in a no-log request, keeps its decision open (held_call_unresolved at run end). A pre_mcp_call guardrail listed after Tracekit's that rewrites the arguments changes what runs after the decision. The result comes through LiteLLM's logging hooks, and LiteLLM runs guardrails in the order configured. List tracekit last among pre_mcp_call guardrails; read held_call_unresolved gaps on runs whose MCP servers ask for input. LiteLLM
An ask at the LiteLLM guardrail holds the MCP request, and a thread of the proxy's default executor, for up to approval_wait_s (default 300 s). An MCP client that times out sooner gets no result, though the call may still run once approved; an approval the guardrail stopped waiting for stays requested until it expires, as at the MCP gateway. The guardrail waits inside LiteLLM's pre-call hook, which LiteLLM awaits before it runs the call. Set the MCP client's request timeout above approval_wait_s, or lower approval_wait_s; reject approvals whose caller has given up. LiteLLM
In the compose stack the custody services' signer identities are uids on the socket the agent container mounts too: an agent container running as root, or keeping the SETUID capability, could connect as one of them. The signer has one socket and identifies its peers by uid. Run agent containers as a non-root user with cap_drop: [ALL] and no-new-privileges (as the example agent service does), or deploy on Kubernetes, where each service has its own service-account identity. executor
The agents' egress NetworkPolicy allows DNS to kube-dns, which a DNS tunnel can use, and doesn't allow a NodeLocal DNSCache (169.254.20.10), so DNS fails where one runs. Agents need name resolution; the chart can't know the cluster's DNS layout. Filter DNS (a DNS-aware policy such as Cilium's, or an allow-list in CoreDNS) where tunnelling matters; with NodeLocal DNSCache add an egress rule for 169.254.20.10/32 port 53. Kubernetes
Agents need a projected service-account token per custody service in the central chart, of that service's audience (<audience>-<name>), besides the signers'. A token a custody service receives must not be one the signers take. Mount one projected token per service the agent calls; see the example. Kubernetes
With an LLM gateway on the same run (or an integration that records the model's messages), each MCP gateway or agentgateway ext_mcp processor call, and each executor call made without the model's tool-use id that the signer can't pair with the model's call by arguments, reconciles as reconcile.fabricated (plus reconcile.hook_missing with the gateway). They decide under an id of their own: MCP's tools/call carries no tool-use id, and a LangChain or Claude Agent SDK tool handler gets one only from the Tracekit adapter (the LangChain middleware, the Claude Agent SDK's tracekit_hooks). Read such a pair as one call through the gateway or the executor (the decide's source is gateway); run the executor helpers under the Tracekit adapter, or pass tool_call_id where the framework exposes it, as the OpenAI Agents and Vercel AI helpers do. executor, MCP gateway
The MCP gateway holds one session per upstream, shared by every agent and run; one that went away is opened again by the next call, and a call in flight when it went away answers upstream error and may have run. It opens each upstream once and keeps it; it can't tell whether a call cut off mid-flight ran. Run one gateway per tenant when an upstream keeps per-session state; check an upstream error call's effect before making it again. MCP gateway
agentgateway's ext_mcp processor can't hold a call for a person: an ask is refused after 5 s, and passes only when the agent makes the same call again (same run, caller, tool and arguments) after a person approved it. A processor restart forgets pending asks, so the retry asks again. agentgateway gives a processor 10 s per call. Tell the agent to retry a call refused with approval requested once it is approved; approve with tracekit approvals. agentgateway
agentgateway's ext_mcp processor decides only tools/call: resources/read, prompts/get and the other MCP methods go through agentgateway undecided and unrecorded. The processor speaks for tool calls; a resource or prompt read isn't a call the policy has a class for. List only tools/call in the processor's methods, and put MCP servers whose resources or prompts matter behind Tracekit's MCP gateway instead. agentgateway
A tools/call agentgateway answers with a JSON-RPC error gets no result from the ext_mcp processor, and neither does one whose result comes back without tracekit_call or after a processor restart: its run ends with a held_call_unresolved gap for it. agentgateway's response hook skips errors and carries no call id of its own. Keep the tracekit_call metadata entry in agentgateway's config (the processor says on stderr when a result names no call it passed); read such a gap as a call that may have failed or run; agentgateway's spans (mcp.error.code) show which failed. agentgateway
The ext_mcp processor's records are T2 only if the agent reaches its MCP servers only through agentgateway and can't connect to the processor. It listens on loopback without TLS and takes the caller and the results it is sent as agentgateway's. It can't authenticate agentgateway, and it knows the agent only from agentgateway's metadata. Run it in agentgateway's pod or on its host, never where the agent runs; fence the agent's egress to MCP servers; build caller from what agentgateway authenticated (source.spiffeId, jwt.sub), never from a header. agentgateway
agentgateway forwards the agent's headers, X-Tracekit-Run included, to HTTP MCP servers on every request but a tools/call the ext_mcp processor passes, whose answer removes it. It passes client headers through to its targets, and only tools/call reaches the processor. None needed: a run token is bound to the agent's identity and is no use without the agent's credentials. agentgateway
A Wassette permission grant approved through the MCP gateway stays in effect after the call: every later call of that component, from every agent and run behind the gateway, has it, after restarts too, until it is revoked. Wassette saves permissions with the component, not per run or caller; the approval covers the grant call. Serve a fixed tool set (--manifest, --disable-builtin-tools); otherwise run one Wassette per tenant and revoke grants (revoke-*-permission, reset-permission) when the work is done. Wassette
Tracekit doesn't see what a Wassette component does inside Wassette, or permissions set outside the MCP gateway (the manifest, the wassette permission command, a component's policy file, an MCP client connected to Wassette directly). The manifest isn't signed into the run. Only calls through the gateway reach the signer. Let only the gateway reach Wassette (on loopback, on the gateway's host), and keep its manifest and component directory writable only by whoever deploys it. Wassette
A Wassette component's own failure (a host its permissions don't allow: {"err":"ErrorCode::HttpRequestDenied"}) is recorded as an ok result. Wassette returns it in a normal result, isError false, and the gateway records the status the upstream gives. Read the result, not only its status. Wassette
The TypeScript executor helpers cover OpenAI Agents and the Vercel AI SDK, not the Claude Agent SDK. Its tool() takes only a zod shape, and actions declare JSON Schema; converting needs a dependency. Write a tool() per action with its zod shape whose handler returns action.call(args). executor
OpenAI Agents' shell and apply_patch tools don't run the signer's copy of T2 arguments; function tools do. They run the SDK's action object, which the gate read just before it runs. Route such actions through the executor if they need T2. approvals
The Codex, Cursor and Gemini CLI hooks can't replace a call's input: under T2 they run the arguments the signer consumed, which it checked against the approved ones. Those harnesses take no updated input from a hook. Use Claude Code or the executor where the approved copy itself must run. approvals
The OpenShell middleware decides only the HTTP requests OpenShell allows to the hosts its policy entry includes, and their responses: not other hosts, not traffic OpenShell can't inspect as HTTP, and not WebSocket messages; without its OCSF import, OpenShell's own network-policy decisions aren't in the run either. A WebSocket upgrade is decided, but its call never completes, so the run ends with a held_call_unresolved gap for it. OpenShell selects middleware by destination host and passes it only the HTTP it inspects; the middleware doesn't take WebSocket messages, and OpenShell runs no response middleware on an upgrade. List every host the sandbox's network policy allows under the entry's endpoints.include, keep on_error: fail_closed, and turn on the OCSF import (ocsf) to record OpenShell's own blocks. OpenShell
A request the policy asks about is denied at once (tracekit_approval_required); it runs only when the agent sends the same request again after a person approves it. A retry that changes a header (a trace or idempotency id) is a new request and asks again. OpenShell waits for a middleware's answer at most 30 s, and the approval is bound to the exact request. Approve with tracekit approvals approve, then let the agent retry; where asks matter, use clients that send the same headers on a retry. OpenShell
The OpenShell middleware denies a request whose body isn't UTF-8 text (gzip, protobuf, a binary upload) or is over 512 KiB (tracekit_body_unreadable), whatever the policy, and the run has no record of it (the middleware prints it on stderr). A text body over 64 KiB is denied by every rule that reads the whole call (TK-OVERSIZE, the server pack's secret rule among them). The policy decides on what it can read; a decision on a body's digest would let a compressed or padded secret through the content rules. Send request bodies as uncompressed text under 64 KiB to the hosts the middleware's entry includes; route a host that needs binary uploads through another policy entry, where OpenShell's own policy governs it. OpenShell
A response body OpenShell doesn't offer to middleware (compressed, partial, bodyless, no-transform), a binary one or one over 512 KiB completes its call with a digest. Under a policy that lists http_request as untrusted (the server pack), that makes the run's provenance index partial, and from: untrusted rules then hold every request that sends data. The signer can index only content it receives. Have the agent's HTTP client ask for uncompressed responses (Accept-Encoding: identity) where provenance rules matter. OpenShell, policy
Without gateway_jwt the OpenShell middleware takes any peer that reaches its address for OpenShell, so it refuses to listen beyond loopback unless allow_unauthenticated: true. With it, an OpenShell token is a bearer credential until it expires (an hour at most), and one gateway's key signs every sandbox's. OpenShell authenticates extension calls with a short-lived EdDSA token only, and sends none over allow_insecure_transport. Set gateway_jwt (the gateway's gateway_jwt.gateway_id, its public key, the registration's audience) with tls; never include the middleware's address in a sandbox's network policy. OpenShell
At most 64 responses stream through the OpenShell middleware at once, shared by every sandbox: one sandbox's slow downloads can hold all 64 (OpenShell's deadline is per exchange, not per stream). One more is refused (RESOURCE_EXHAUSTED), which OpenShell handles by the entry's on_error (fail_closed: the sandbox doesn't get that response), and its call ends as a held_call_unresolved gap. Each open response stream holds a thread; the threads kept for request evaluations must stay free, or every sandbox's requests would wait. Keep hosts the sandboxes download large or slow responses from out of the middleware's endpoints (another policy entry); the middleware prints each refused response on stderr. OpenShell
The OpenShell middleware speaks supervisor-middleware protocol 1.0 only. It refuses OpenShell before 0.1.0 (NemoClaw pins 0.0.116) and any other protocol version, and the OpenShell gateway then doesn't start. OpenShell's middleware API is still changing; a version the middleware doesn't know may change what a message means. Run OpenShell 0.1.0 or newer; upgrade Tracekit when OpenShell changes the protocol version. OpenShell
The OpenShell middleware keeps each sandbox's run, its held approvals and its open requests in memory. After a restart a sandbox gets a new run, a request approved before it asks again, and the response to a request allowed before it isn't recorded (that call ends as a held_call_unresolved gap). Replicas behind one endpoint each register their own run per sandbox, and with ocsf replicas under one signer identity stop each other's OCSF import (the signer refuses the process another took over from, conflict). The run tokens never leave the process. Restart it rarely and run one replica per endpoint; read a sandbox's runs together (their ids start openshell-<sandbox id>-). OpenShell
The OCSF import's calls count against the middleware's own event rate (limits.events_per_s, burst: 200 a second by default), one budget for every sandbox's events, not one per sandbox as its calls have: around 100 busy sandboxes spend it, and then OpenShell's batches are refused (UNAVAILABLE) and sent again, and those it gives up on become its gap records. The import is one observer, the middleware's identity; its effects are attributed to the sandboxes' runs only by their run tokens. Raise the middleware identity's limits in signer.yaml with the number of sandboxes; read a run's observer_lost gaps for what was dropped. OpenShell
OpenShell's OCSF events are recorded as OpenShell reports them: a supervisor or gateway that reports falsely is believed. One bearer token authenticates the gateway and every supervisor, so an event's sandbox is the one the sending OpenShell component names. OpenShell's OCSF export carries only the collector headers its gateway is configured with, the same for every supervisor, and its events are unsigned. Set ocsf.token_file with tls; read OpenShell's events as its report, beside the requests Tracekit decided itself. OpenShell
The OCSF import records OpenShell's network, process and policy-change events only. Other classes (SSH, authentication, detection findings other than a fail_open bypass, which is a signed gap, application lifecycle, base events), network events that neither allow nor deny, and events naming no sandbox are counted on the middleware's stderr, not recorded. OpenShell reports its sandbox's entrypoint, not each process the agent starts. Tracekit's records have no form for those classes yet; OpenShell's process events cover only what it starts. Keep OpenShell's own OCSF JSONL output ([openshell.gateway.ocsf_log], ocsf_json_enabled) for the rest; run a kernel sensor to see every process. OpenShell
OpenShell's OCSF export is best-effort: it drops events when its queue is full or a batch fails four times (its next batch reports how many, which becomes a signed gap), and events lost while it shuts down get no report. A sandbox whose export stops is noticed only once the middleware has decided one of its requests, 60 s later. OpenShell counts its own drops but numbers no events, so a loss it doesn't report can only be inferred from silence; every request the middleware decides is one event OpenShell should send. Keep the middleware and the signer reachable from every supervisor; read a run's observer_lost gaps before trusting the absence of a block. OpenShell
The OCSF import covers only the runs the middleware registered (the sandboxes'): when the middleware stops or restarts, each of those still open gets an observer_lost gap; other runs of the tenant get none. An event for a sandbox whose run the signer already closed (idle_s) is an effect_outside_run gap in the tenant's run tracekit/signer, until the sandbox's next request opens a new run. The import names the middleware's identity as its one caller (callers), so it covers that identity's runs alone; the middleware learns that a run closed only from its next call for that sandbox. Set idle_s above the sandboxes' quiet periods. OpenShell
Behind the MCP gateway, Docker's MCP Gateway with its dynamic tools on (mcp-find, mcp-add, mcp-exec, code-mode and others) adds servers and runs other tools from inside one call, which Tracekit decides as that one call. Docker's gateway makes those calls itself; the MCP gateway decides only what it forwards. Start each Docker upstream with --servers=<server>, which turns them off (or docker mcp feature disable dynamic-tools). Docker MCP Gateway
Every server behind one docker mcp gateway run upstream is decided under that upstream's name (mcp:<upstream>/<tool>), so rules keyed on the server (mcp:fetch/*) don't tell them apart. The MCP gateway names a call by its upstream, and Docker's gateway doesn't prefix its servers' tools by default. Give each Docker server its own upstream, named after the server. Docker MCP Gateway
The MCP gateway doesn't cut the secrets Docker's MCP Gateway gives its servers from what it answers and records, and doesn't see the image, mounts or network a call ran with. It knows only the env and headers values in its own config; Docker keeps its secrets and containers to itself. Keep Docker's --block-secrets on (the default): it refuses a call whose arguments or text result look like they carry a secret. Docker MCP Gateway
An agent that can use Docker (the docker group, a mounted Docker socket, Docker Desktop as its own user) can start Docker's MCP Gateway itself and call its servers around Tracekit. Custody covers only the calls routed through the MCP gateway. Run the agent as a user without Docker access, and the MCP gateway as one with it. Docker MCP Gateway
Docker's MCP Gateway's OpenTelemetry spans (mcp.tool.call) aren't imported as tool calls: the OTLP import counts and skips them. They carry Docker's own attributes (mcp.tool.name, mcp.server.name), not the conventions the import reads, and no arguments or results. Record Docker's tool calls through the MCP gateway. Docker MCP Gateway

Egress observation (1.2)

Limit Why it is so What to do Details
The egress observer sees hosts, ports, byte counts and times, never paths, headers or content: a call to an explained host can still carry anything. It doesn't intercept TLS, so it never holds the agents' traffic in clear or a CA they would have to trust. Route actions whose content matters through the executor or the MCP gateway; read effect.observed as where the run went, not what it sent. egress
Attribution by identity (no run token in the proxy URL) gives an observation to the identity's latest open run, which is ambiguous when one identity runs several at once. The record says attribution: identity. The proxy knows who connected, not for which run, unless the client says so. Set HTTPS_PROXY=https://<run_id>:<run token>@... per run (attribution: token); in system mode one agent user runs one session at a time. egress
posture (network: enforced, tool_calls: enforced) and gateway_mandatory are what the operator asserts in signer.yaml: run.registered records them with attested: false, and the matrix's enforced for model calls, tool calls and network rests on them (asserted). Without tool_calls: enforced, tool calls are at most reported, even when every decision came through the executor or the MCP gateway. The signer can't see the cluster's network policy, where provider keys live, or whether the agent has another way to act than the calls it saw. Back it with the central chart's agentEgress NetworkPolicy on a CNI that enforces egress, keys only in the LLM gateway, agents with no tools of their own, and test each from an agent pod. egress
A host only explains covers (CDNs, redirects, package indexes) is reconcile.unexplained_effect until it is listed, so a fresh deployment sees many. The server pack only notifies on them (shadow). The signer knows the hosts of http calls, not what a shell command or a library contacts beneath them. Tune the policy's explains from the warnings (the detail names the host) before you set unexplained_effect to enforce. egress
An explains host glob over a public suffix of two labels (*.co.uk, *.github.io) passes lint and explains every host under it, whoever holds it. Lint only requires a dot and two or more labels after the last wildcard; it has no public suffix list, and explains is the operator's own policy. Write the narrowest host you mean: the hosts themselves, or a suffix one owner holds (*.cdn.example.co.uk). egress
A laptop without Linux system mode's --egress has no observer: its network is reported (http calls) or not covered. --egress needs Linux with iptables' owner match; macOS has none. Only a rule outside the agent's user can force its traffic through the observer. Use sudo tracekit init --v2 --egress on Linux, or run agents on a server deployment behind the egress observer. egress
An observer covers the runs of its tenant registered while it is alive, and which runs it covers lives in the signer's memory: a run registered before its first heartbeat, or open across a signer restart, is not observed. After a restart with no snapshot since, a run's earlier http calls no longer explain a host. The heartbeat watch is the transcript tailer's, which keeps no state in the log. Start the observer before the agents; re-check runs that span a signer restart. egress
A run an observer covers reaches run.final up to observe_grace_s (default 120 s) after it closes; an observation after that goes into the tenant's signer run as effect_outside_run, not reconciled. The observer reports each minute once it ends, at its next heartbeat; a final run takes nothing more. Raise observe_grace_s if effect_outside_run gaps name late runs; export them with --run-set. egress
One attribution (a connection's identity, else its run) has at most 64 tunnels open through the egress proxy at once; one more is closed before it reaches its host. On Linux a loopback client's uid has at most 64 connections open, counted from accept, handshakes included, and every client sends what attributes it within 5 s. The proxy takes at most 256 connections per client address: in Linux system mode every agent connects from loopback, and each uid keeps the others' share; elsewhere (no uid to read, or one address for many sandboxes) one client's half-open connections can still take that address's 256, each for 5 s. So one agent can't hold every connection its address may open, which would lock the other agents out. Reuse connections (HTTP keep-alive, HTTP/2) in agents that open many at once; run agents that need more under identities of their own; give each untrusted client an address of its own. egress
The proxy reads the server name from the ClientHello's first TLS record only, knows the public DNS-over-HTTPS endpoints by name only, and lets an allowed host through with no SNI check (any protocol). A split ClientHello is rare; DoH on another host is HTTPS to that host, which is recorded as such. Keep allow to the hosts that need it; DoH elsewhere shows as traffic to its host. egress
Only connections made with a run's proxy_env (proxyEnv) carry its token: the SDKs never set the agent process's own HTTPS_PROXY, so its in-process connections reach the observer only when the deployment proxies them, attributed by identity (see the next row). Proxy settings are per process and its clients read them once; the run token is per run, and one process can hold several runs. Give run.proxy_env to the commands each run starts and to the HTTP clients it uses; set the agent's HTTPS_PROXY in the deployment for the rest, with the signers and custody services in NO_PROXY. egress
The executor and the gateways reach their upstreams directly, not through the observer: their calls are recorded at T2 already, and the agents' NetworkPolicy doesn't restrict them. Their records name each call's host; an MCP gateway's upstream sessions are shared by every run, so no one run token fits them. Read their records for where they went; restrict their own egress with the cluster's network policy. egress
A host the observer's allow doesn't list must resolve to public addresses only: a name resolving to a loopback, private or link-local address is refused (403), internal services included. Otherwise the proxy would open the agents' way to everything only it can reach (cluster services, cloud metadata). Add internal hosts agents may reach to allow, or to the agent's NO_PROXY when it reaches them directly; the 403 names the address. egress
An observer covers only the runs of its own tenant (signer.yaml tenants). Its observations are written as its tenant's records. Run one observer per tenant. egress
An observer that heartbeats after a run closes and then dies before its next report leaves the run network: observed with no observer_lost gap: the totals of its last minute, before the close, are lost. Coverage rests on heartbeats; a beat inside observe_grace_s counts as covering the run, and nothing marks that the last minute's report followed. Keep observe_grace_s at 90 s or more (two report intervals), run the observer under a supervisor that restarts it, and read a short-lived run's observed network with this in mind. egress
Attempts the proxy refuses (a raw IP, a private or metadata address, a DNS-over-HTTPS endpoint, an SNI mismatch, a name that doesn't resolve) leave no signed record; only the agent sees the refusal. The proxy resolves every name an agent sends it, so a name can carry data out through DNS (<data>.evil.example) whether or not the connection is then refused. The observer records the connections it opens; a refusal comes before any, and resolving the name is how it decides. Treat the observer as a record of connections, not of attempts; to catch exfiltration through DNS, log or restrict the resolver the proxy uses. egress
The observer keeps at most 10,000 observation totals the signer refused, and sends them again with every heartbeat; past that it drops the oldest, says how many on stderr, and they leave no signed record. A refusal that never changes would otherwise grow the queue without end. Fix what the observer's stderr names (its grants, its tenant) before the queue fills, and watch that stderr for dropped. egress
Model calls explain only the public API host of their provider (api.openai.com, api.anthropic.com). The record names the provider, not the endpoint. Send other endpoints through the LLM gateway, which makes those connections itself. egress
Over the network (the central chart, compose) a plain HTTPS_PROXY client without a run token is refused (407): attribution by identity there needs Proxy-Authorization: Bearer <token> or a client certificate, which proxy settings can't carry. Only system mode attributes by identity alone (the agent's uid). The proxy knows a networked client only by what it sends. Give the agent process a run's proxy_env URL as its proxy, or a client that sends a bearer token of the egress audience. egress
The proxy checks each run token with the signer before it opens a connection: one round trip per connection, which costs none of the observer's event rate, and while the signer can't be asked, requests with a run token are refused (503, saying so). Only the signer can check a token (an HMAC with its secret); an unchecked one would let a client name any run. Keep the signer reachable from the observer; clients retry a 503. egress
Totals the signer can't take, or refuses, wait in the proxy's memory (a refused one is sent again with every heartbeat); a killed proxy loses what it had not reported, up to a minute and a heartbeat's. Any restart of the proxy, even after SIGTERM sent everything (a rolling update too), is an observer_lost gap in each open run it covered, and so a second proxy process under the same identity: run one replica per observer identity. It reports per minute to keep records few; the signer can't tell a clean restart from a crash, nor a second process from a restart. Stop it with SIGTERM, which sends every total, and restart it while no run is open; read its stderr line on a refusal. egress
Linux system mode's firewall rule lets the agent's uid reach every loopback address, so a loopback service can send on for it unobserved: a local DNS stub (systemd-resolved's 127.0.0.53) resolves any name the agent asks, and the lookups themselves can carry data. The rule allows loopback for the observer and the agent's own local services; it can't tell the DNS stub from them. Reject the agent uid's DNS to loopback (port 53) in your firewall if all its clients use the proxy, which resolves names itself; otherwise read the resolver's query log. egress
Plain HTTP requests through the proxy need a Content-Length (no chunked request bodies); HTTPS goes through CONNECT and is unaffected. The proxy forwards one request per connection. Use HTTPS, or send a length; the proxy answers 411 saying so. egress
Through microsandbox the observer sees hosts (each connection's TLS server name), ports, bytes and times: never paths, headers or content, nothing microsandbox resolves through its DNS forwarder, nothing sent to host.microsandbox.internal, and nothing inside the microVM. It doesn't intercept TLS; microsandbox sends DNS and host-local connections around the proxy. Restrict DNS and host-local access with microsandbox's network policy; route actions whose content matters through the executor or the MCP gateway. microsandbox
Over SOCKS5 (microsandbox) a connection to an address reaches it only when its TLS server name resolves to that address on the observer's host: plain HTTP and other non-TLS TCP are closed unless allow lists the address, and UDP (SOCKS5 UDP ASSOCIATE) is refused. microsandbox sends the address it resolved, never a name; the server name is the only name the observer sees, and it records one only when its own lookup agrees. Use HTTPS; list fixed addresses the sandboxes need in allow; QUIC clients fall back to TCP. The stderr line names each closed connection. microsandbox
A name whose DNS answers change between microsandbox's lookup and the observer's (a CDN rotating addresses) can close a connection. The observer relays to the address microsandbox chose, which its policy checked, and records the name only when its own answer includes that address. Point microsandbox's nameservers at the resolver the observer's host uses, ideally a caching one; retry a closed connection. microsandbox
Per-run attribution from microsandbox needs per-sandbox SOCKS5 credentials (the run id and token); a proxy enforced in microsandbox's managed configuration replaces them, which leaves attribution by identity: the uid running msb on Linux (its latest open run), and on macOS no attribution, so every connection is refused. On Linux that uid goes with the token too, so a run registered by another identity is attributed by the uid. microsandbox's managed proxy is one object for every sandbox on the host, credentials included; the observer reads a loopback client's uid only on Linux. Set credentials per sandbox from the code that creates it, and register the run as the user that runs msb; enforce the proxy in managed configuration only where one user runs one run at a time. microsandbox
A SOCKS5 password holds at most 255 bytes, and a run token grows with the name of the identity that registered the run: a long one doesn't fit. RFC 1929 gives the password one length byte; the token carries the run id, tenant and identity. Register runs under a short identity (a uid, a short SPIFFE ID). microsandbox
E2B's egressProxy works only on E2B Cloud and BYOC, for teams that have it enabled; deployments built from the open-source e2b-dev/runtime reject it. Its client isn't open source: Tracekit is tested against E2B's API description, not E2B itself. Without it, a proxy set in the sandbox's environment (envs) is the agent's to ignore. E2B gates the feature per team, and only its closed builds carry the proxy client. Ask E2B to enable egressProxy for your team; check that a sandbox's first connections show up as effect.observed before you rely on it. E2B
E2B tunnels TCP only: a sandbox's UDP (DNS, QUIC/HTTP3) leaves directly, unobserved. Plain HTTP and other non-TLS TCP through E2B reach only hosts and addresses allow lists. E2B's egress proxy is SOCKS5 CONNECT; the observer names a host only by a TLS server name it can check. Drop the UDP a sandbox doesn't need with denyOut (E2B applies it to non-TCP traffic in this mode) and keep allowOut to the hosts it needs; use HTTPS. E2B
E2B's SDKs send no tls for egressProxy, and off loopback the observer takes SOCKS5 only inside TLS: a sandbox created through the SDKs with the observer as its proxy has no network. The run token is the SOCKS5 password, and the observer takes no credentials in clear off loopback. Create the sandbox through E2B's API with network.egressProxy.tls.enabled: true. E2B
The run token E2B holds as the SOCKS5 password lets anyone holding it send traffic through the observer as that run. The observer attributes a connection by the token it presents. A token alone can't write to the run: the signer checks it against the identity it was issued to. Close each run when its sandbox ends: an observation for a final run goes to the tenant's signer run as effect_outside_run. E2B
The connections of every sandbox on one microsandbox host (loopback) or one E2B node come from one address, which shares the observer's per-address limits (256 open connections, failed run token checks); on Linux every sandbox of one msb user also shares that uid's 64 open connections. The limits stop one client from taking every slot; the observer knows a client by its address. Keep sandboxes' long-lived connections few; spread E2B sandboxes across several observer addresses. egress

Followed sandbox logs (tracekit follow)

Limit Why it is so What to do Details
A followed record is agentsh's word, signed when it arrived (policy.external, source import, tier T3). Tracekit doesn't check that agentsh logged everything, or agentsh's own hash chain. The follower reads the log agentsh writes; only agentsh sees its decisions. Check agentsh's log with its own tools as well (agentsh audit verify); each gap's reason says where to look. agentsh
The follower's identity is trusted to import agentsh's decisions: an agent that can present it can import made-up ones. importers and decision_import are grants to an identity; the signer can't check agentsh's log. Give the follower an identity of its own, such as a signer token readable only by the user the follower runs as. agentsh
The signer watches a follower's runs for heartbeats from memory: a run open across a signer restart with no snapshot since isn't watched, so a follower killed then leaves no gap. The watch is the transcript tailer's, which keeps no state in the log. Restart the signer while the follower's run is closed (after the signer's idle_s without a decision), or read a run that spans a restart with this in mind. agentsh
Without agentsh's audit.integrity, its lines carry no sequence, so a line missing from the log is no gap. The sequence comes from agentsh's integrity chain. Turn on audit.integrity in agentsh's config. agentsh
tracekit follow agentsh starts at the log's end: what agentsh wrote before, or while the follower was stopped, isn't imported. The follower keeps no position on disk. Start the follower before agentsh's sessions; a stop is a gap that says where it stopped. agentsh
One run holds all of agentsh's log, and a record doesn't name agentsh's session: that is in the committed line only. The follower follows one file, which every session writes to. Find the line in agentsh's log by the record's tool_use_id, agentsh's event id. agentsh

Kernel sensor import

Limit Why it is so What to do Details
Tracekit records only what the sensor exported and the import read: events no TracingPolicy or Falco rule selected, events an export filter or field filter dropped, alerts whose output lacks a field the import needs, and events before the import's first heartbeat aren't in the run, and nothing marks them. Connects to no IP address (unix sockets) are skipped, counted on the import's stderr only. The import sees the sensor's output, nothing else; the kernel side is the sensor's. Use the sample TracingPolicy and export allowlist, or the sample Falco rules; check once that a test agent's exec, connect and write outside the workspace each show up as effect.observed. Tetragon, Falco
Tetragon numbers no events: only the drops it reports in its export (rate_limit_info, process_throttle) become signed gaps. Drops it counts only in its metrics (ring buffer, event queue, export failures) don't. A missing event can't be seen in a stream without sequence numbers. Alert on tetragon_observer_ringbuf_events_lost_total, tetragon_observer_ringbuf_queue_events_lost_total and tetragon_events_export_failed_total; leave --export-rate-limit off or high. Tetragon
A loss is a gap in every run the import covers (the open runs of its runs entries' callers), whether or not the dropped events were those runs'; a run registered before the import's first heartbeat gets none (a loss while it covers no run goes to the tenant's signer run). The sensors' loss reports name no process; coverage is the runs the import covered throughout. Start the import before the agents; read observer_lost gaps next to the sensor's metrics. Tetragon
One import identity is one process: a second process of it calling is taken for a restart (an observer_lost gap in each run it covers), and the first is refused from then on (conflict). With an import per node, each with its own identity, an import's restart, silence or loss is a gap in the open runs of every node, and a run says its processes were observed only by the import that recorded at least one of its events, so a run with none says not observed. The signer knows a run's identity, not the node its processes run on. Give each node's import its own identity; or run one import for the cluster that reads every node's export (gathered by your log pipeline), which covers each run alone and gaps only for its own losses. Tetragon
The records are the import's assertion of what the sensor said. Tracekit doesn't check them, and a root user on the node can change the sensor, its output or the import. The signer can check who sent an event, not whether the kernel saw it. Give the import its own identity, never an agent's (the signer refuses an import that owns the run); keep agents unprivileged on the node. Tetragon
(1.4) An observer's identity is of one kind while the signer runs (one sensor's import, or an egress observer): a call as another kind is refused. A run whose owner the import's runs entries stop naming is no longer covered by it, so its run.final says processes not observed. run.final says what covered a run by the kind of each observer covering it. Give each import and each egress proxy its own identity; restart the signer to give one a new kind. Tetragon
One import maps processes to at most 256 identities (its runs entries' callers). Every call names them, so the signer covers only their runs. Run one import per group of agent identities. Tetragon
An event goes to the latest open run of the identity its runs entry names registered by the event's time, which is ambiguous when one identity runs several at once; the time is the sensor's clock, so with it behind the signer's an event can go to the run before (one still open) or, more than 5 s behind with none open, be an effect_outside_run gap. A sensor's events name the pod, container and uid, not the run. One identity per concurrently running agent, or one pod per run; keep the nodes and the signer on NTP. Tetragon
An event is joined to a tool call only through CODEX_TOOL_CALL_ID, which the agent's process can change or remove; Claude Code exports no such id. An effect nothing explains isn't reconciled: no reconcile.unexplained_effect, no count in run.final's coverage. The id is the only link between a process and a call; a process with none can't be told from one that hid it. Run tracekit analyze --v2 on the finished run: TK-X206 joins each effect to a call by its claim, its parent process or the call's time, and flags what none explains. Tetragon
The binary, file paths and connected addresses are stored in the clear; the arguments are committed to, not stored. Auditors need what ran and where; arguments can hold secrets. Test arguments from Tetragon's own export against the commitment with the record's revealed salt (tracekit signer reveal). format v2
With --follow the import reads from the file's end on: events written while it was down aren't imported (its restart is an observer_lost gap). It doesn't read rotated or compressed files. Reading the file again from the start would record events twice. Run the import as a service that restarts, next to Tetragon. Tetragon
Falco reports a second's drops only when more than syscall_event_drops.threshold (10 %) of its events were dropped, at most one second per 1/rate (30 s) by default, and needs priority: debug for the report at all; alerts dropped from its full output queue show only in its log and metrics. Those are Falco's settings for its drop alert; the queue has no alert. Use the Falco settings in the setup (threshold: 0, rate: 1); alert on falcosecurity_falco_outputs_queue_num_drops_total. Falco
A Falco restart leaves no mark in its alerts, so what happened while it was down is missing with no gap. A Tetragon restart is a gap: it reports the processes already running (procFS). Falco writes no alert when it starts. Alert on Falco's restarts in your monitoring (its pod's restart count); prefer Tetragon where a restart must be in the run. Falco
With Falco, only alerts of rules tagged tracekit are recorded, and none of a process's exits; records have no exec_id. Other rules' alerts would record the same system call twice; the sample rules match no exit, and Falco names no process across executions. Use the sample rules; join a process's records by pid and time. Falco
A bundle with kernel sensor records (effects, or an observer_lost gap of its import) needs verifier 1.4.0 or later. Older verifiers don't know the records' fields. Verify with 1.4.0 or later: verdicts has the commands. format v2

Signed findings

Limit Why it is so What to do Details
A signed finding reads independent only when your trust config pins its author in analyzers; otherwise unpinned author <id>, and self-asserted when the author is the analysed run's identity or a run's signer runs as its client's user. Every finding of the dev signer is self-asserted. The signer measures who wrote a finding; only you can say whom you trust to analyse runs. Run tracekit analyze --v2 as an operator identity, never the agent's (tracekit doctor warns: D-ANALYZER-AGENT), and pin it in your trust config: "analyzers": ["uid:1004"]. verdicts
A signed finding is the analyzer's word. The verifiers check that its citations are records of the analysed run with those hashes, not that the check was right, ran on every record, or found everything; a missing finding proves nothing. A finding holds no text of the run, so a verifier can't run the checks again from a bundle. Read the cited records (tracekit view), or run tracekit analyze --v2 again with access to the content vault. format v2
A bundle of a findings run without the run it analyses verifies with its findings not counted (reviews counted warning) and their author not checked. The citations and the author's comparison need the analysed run's records. Export the analysed run with tracekit export --v2 --run R --with-findings. verdicts
A findings run or a tracekit why replay run can't itself be analysed or replayed: the signer refuses to register a findings or replay run about one (invalid_request), so tracekit analyze --v2 --run on one prints its findings unsigned and exits 2. Findings and replays are about the agent's run they name; findings about findings say nothing of what the agent did. Analyse or replay the run it names (analyzes or replay_of in its run.registered). findings, why
The verdict of a signed finding is a closed schema: a check whose finding needs another rule, field or reason code needs a new RPC version, in the signer and the analyzer. The schema is how the signer keeps text of the run (excerpts, paths, reasons) out of records that can't be erased. Upgrade the signer with the analyzer. format v2
A bundle with signed findings needs verifier 1.4.0 or later. A v1 judge verdict carried into a v2 run (a review without citations) is counted as imported, never checked. Older verifiers don't know cites; an imported verdict cites nothing to check. Verify with 1.4.0 or later: verdicts has the commands. format v2

Witnesses and monitor

Limit Why it is so What to do Details
A witness you run yourself (operator class) gives only Assurance: local. Whoever runs the stack runs the witness too. Have someone else run a witness (customer, public) on infrastructure you can't change. deploy compose
Witness classes come from the verifier's judgement, never from the bundle. The bundle's producer can't vouch for its own witnesses. Get each witness key from the witness's operator. auditor guide
A git witness without a remote, or a file witness on the same host, adds little. Whoever controls the host can rewrite it. Use a remote with a protected branch, or storage the host can't rewrite. witnesses
An unreachable witness delays cosigning; the gap is signed, not prevented. The signer can't force a witness to answer. Watch witness_failed gaps and tracekit_signer_witness_lag_records. witnesses
The v1 Rekor witness is experimental, off by default and not run against the live service. It is tested offline only. Treat its first real run as validation. witnesses
Rekor entries are public and permanent and reveal activity timing; the publishing key links a signer's anchors. Rekor is a public transparency log. Use Rekor only where that is acceptable. witnesses
Rekor v2 anchoring needs a signing config that lists a Rekor v2 log; the TUF files are pinned local copies, not fetched. Production Sigstore may not list one yet; TUF fetching isn't built. Start on staging; refresh the files when Sigstore rotates a shard. witnesses
macOS's system openssl (LibreSSL) can't verify the RFC 3161 timestamps. It fails on their ESSCertIDv2 attribute. Use OpenSSL 3 or tracekit verify. witnesses
A monitor sees the log the signer serves it: a split view shows only at equal tree sizes, or to witnesses. A smaller checkpoint isn't proven consistent with the report's tree. Run monitors and witnesses the operator doesn't control. server threat model
A monitor the operator controls vouches for nothing, and a monitor report never raises dev or local. Monitoring adds to independent witnessing, it doesn't replace it. Run it somewhere the operator can't change; pin it as non-operator. monitor
With an issuer, the monitor flags each signer.epoch after seq 0 as an unannounced key. Rotating kids can't be listed in advance with --allow. Expect those conflicts until the monitor can pin issuers. issuer
A Rekor entry the signer wrote but got no reply for shows as a monitor conflict. It matches no stored anchor. Check the signer's witness_failed gaps for that outage first. monitor
The issuer handles one issuance at a time and re-reads its log for each. Fine at record-key volumes. Past ~100k certificates it needs an index. issuer
If the issuer can't be reached at start, the signer doesn't start; records signed after a key's not_after fail verification. Certificates are short-lived. Keep the issuer available; watch renewals. issuer
The public witness makes a log witnessed independently of its operator, not of Cygnux. Whoever holds the witness's key could cosign any tree. Add a witness you or a partner run and raise witnesses_required in the trust config. witnesses
witnesses: [public] and init --v2 --public-witness are refused until the public witness is deployed. Its URL and vkey are set in tracekit/public_witness.py once it runs. List your own witness, {url, vkey, class}, meanwhile. witnesses
init --public-witness is for v2 system mode only. The same-user dev signer has no signer.yaml. Run a signer with a signer.yaml (tracekit signer serve --config) and list public there. witnesses
The public witness takes one log key per origin, the first registered: a log under a new key is refused (403, a new log key needs a new origin). Anyone can register, so the first key must be the only one, or anyone could take over a log's witnessing. Set a new origin in signer.yaml when the log key changes. witnesses
On the public witness the first registration of an origin wins: anyone can register a custom origin before its log does, and the log is then refused (409). Registration is open; the witness can't tell who owns a name. Keep the default origin tracekit.local/<log id> (unguessable before the log exists), or use your own witness. witnesses
The public witness deletes an origin never cosigned within 24 hours of registering, or not cosigned for 90 days; the log registers again on its next checkpoint, and the witness's protection restarts from there. A shared table must not fill with abandoned logs. Checkpoint at least every 90 days, or use your own witness. witnesses
The public witness tells anyone whether an origin is registered, and anyone holding an older checkpoint note of a log its current size. 409 carries the size it holds, as the protocol says. Treat a log's size as public once its notes are shared. witnesses
The public witness takes only origins shaped host/path (lower-case host, at least one path segment); others are refused at registration (400, naming the format). The origin names the log publicly, so it should name a host its operator controls. Set origin in signer.yaml to e.g. tracekit.example.com/log. witnesses
The public witness cosigns at most 30 checkpoints per log and client network a minute that grow the log; a busier log gets 429 and is cosigned at that rate, always its newest note. Resent and older notes are not counted. It is a shared, free service. Run your own witness for a higher rate. witnesses
The public witness takes 100 registrations per client network (IPv4 /24 or IPv6 /48) a day, 60 in all a minute (429), and a fixed number of logs in total (then this witness is full). Open registration must not let one client fill it. Signers with many tenants register over several days (retries are automatic); when it is full, run your own witness. deploy/public-witness

Privacy

Limit Why it is so What to do Details
Commands, paths, URLs and search queries are recorded in clear (after redaction). A reviewer needs to see what the agent acted on. Deny sensitive patterns in policy, or keep bundles internal. privacy
Redaction is pattern-based and misses secrets it doesn't recognise. It can only match known shapes. Deny the pattern in policy, or keep bundles internal. privacy
Browser Use's sensitive_data values are masked written out, percent-encoded, as HTML character references or JSON escapes, or a mix of these; any other transformation (base64, a hash, a split or a case change) reaches the signer as it is. The adapter recognises a value by the forms a URL, a page or JSON writes it in. Keep secrets out of URLs; deny a pattern that carries them in policy. Browser Use
v1 hashes are unsalted, so low-entropy content (a short prompt, a known file) can be confirmed by hashing guesses. Reviewers can match known files. Use the v2 signer, which publishes salted commitments. privacy
Sizes, timing and correlation still leak: the same file in two runs links them. Hashes and commitments hide content, not activity. Treat bundles as activity records. privacy
content_capture: full stores content (redacted) in the log and in bundles. Readable content is what the setting is for. Protect such bundles as you would the data. privacy
Revealing a v2 record's salt lets anyone holding the content check that record's commitments. That is how an auditor checks content; one salt opens one record. Reveal only the records an audit needs. privacy
A run-set bundle carries the tenant's registry salt, so it allows testing guessed run ids against that tenant's leaves. The verifier needs the salt to check completeness. Share run-set bundles only with that tenant's auditors. auditor guide
With serve_records: true the metrics port serves every tenant's records (run ids, tool names, commitments). The monitor reads every record. Expose that port only to the monitor. monitor
Witnesses learn when checkpoints happen and how many records exist; the public witness also learns each opted-in log's origin, so how often and how much each named log grows. That is what they cosign, and it arrives when it is made. Choose witnesses accordingly; for the public witness pick an origin that names nothing you want kept private, or don't opt in. privacy, public witness

Deployment

Limit Why it is so What to do Details
A sidecar doesn't protect against whoever administers the node or the PVC. They can reach the signer's volume. A witness off the node detects rollback. deployment
(1.2) An identity authenticated by token, certificate or service account may call only the RPCs authorize grants it. The TypeScript and Python adapters need register_run, decide, approval_consume, complete, close_run and model_event; asks also need approval_request and approval_wait, and the OpenAI Agents JS and Vercel AI adapters approval_list (to apply decisions, and TypeScript's instrument() at exit to keep a run with waiting approvals open: without the grant it closes the run); observe and state_write where the app records inputs or state; status where the signer's content vault is on (without it the integrations keep what the model said as a digest only, and warn); and the coding-agent hooks on a remote signer (tracekit init --remote ... --v2) observe (each prompt) and constraint_add (live constraints). A missing grant refuses the call as forbidden (an ask is refused). No identity has a grant by default. Grant the agent identity that list; with the LLM gateway, the agent must register its runs as the identity the gateway relays. deployment, gateway
A KMS log key doesn't belong in the agent's pod. Workload identity gives the pod's cloud credentials to every container, the agent's too. Keep KMS keys on a central signer (D-K8S-WORKLOAD-IDENTITY). deployment, doctor
The sidecar chart runs one replica with the Recreate strategy. The signer holds its data volume's storage lock. Size the pod for the agent's load. Kubernetes
persistence.enabled: false keeps the log in an emptyDir that dies with the pod. It is for development. Keep persistence on outside development. Kubernetes
The sidecar's NetworkPolicy covers the whole pod, the agent's ports included. NetworkPolicy selects pods, not containers. Account for it when the agent serves ports. Kubernetes
Central: scaling down closes the last replica's log for good, and that ordinal needs a new log before it runs again. Each replica is the only writer of its own log. Leave autoscaling.scaleDown off unless you plan for it. Kubernetes
Central: calls that name no run or approval reach one replica. Runs are routed by their replica prefix. List each replica's approvals through its own Service. Kubernetes
Central and compose don't protect against an operator with no independent witness and monitor, or against the cloud provider. Detection needs both; the provider is out of scope. Add both; keep witnesses on other parties' infrastructure. deployment, server threat model
An agent-sandbox run records the agent's service account, not its Sandbox or pod: Sandboxes that share a ServiceAccount are one identity in the record. The signer records the identity it authenticated, and the pod name isn't part of it. Give each Sandbox its own ServiceAccount when runs must be told apart by Sandbox. agent-sandbox
A Sandbox's RuntimeClass and spec aren't recorded: a run's isolation: remote says only that the signer runs apart from the agent, not how the agent's pod is isolated. The signer sees an authenticated HTTPS caller, not how its pod runs. Enforce the RuntimeClass in the cluster (an admission policy); don't read it from the bundle. agent-sandbox
A run whose Sandbox is deleted before the agent closes it stays open until the signer's idle timeout closes it (idle_s, an hour by default); until then tracekit verify reads VERIFIED TO HEAD n (open). Only the agent or the idle timeout ends a run; the signer isn't told the pod is gone. Export after the idle timeout for a closed run, or lower idle_s; the open run verifies up to its last record either way. agent-sandbox
The agent-sandbox example's end-to-end test runs only by hand; CI doesn't run it. It needs docker, kind, kubectl and the network (it installs agent-sandbox from its release). Run examples/v2/agent_sandbox/e2e.sh before relying on a change to the example. agent-sandbox
On compose, root on the Docker host is the operator. It controls every container and volume. Use an independent witness. deployment
(1.2) The failed-authentication limit of the HTTP services is per source address (4096 addresses remembered, least recently used dropped): a client that rotates through more addresses than that gets a fresh burst each time, and clients behind one NAT address share one limit, so one sending bad tokens can keep the others' OIDC and service-account tokens from being checked until it stops. The limit protects the services that check those tokens (an IdP's keys, the API server's TokenReview); a client certificate or a Tracekit token is still checked locally. Put the services behind a proxy that limits per client, or give clients behind one address client certificates or Tracekit tokens. deploy
(1.2) The LLM gateway, the tool executor and the MCP gateway take the signer's connection limits (1024 in all, 256 per address), so one address can hold many slots with large headers before it is authenticated. Agents behind one NAT address share an address; a lower fixed per-address limit would refuse them. Put the services behind a proxy that limits connections per client. deploy
(1.2) The MCP gateway answers tools/list, resources/list, prompts/list and resources/read from the upstreams before the signer has checked the run token (it is checked when the answer is recorded, which a forged token fails): an identity the gateway accepts can make it call every upstream once per request. The listing is recorded as input the agent got, after it is fetched. Accept only the agents' identities at the gateway, and rate-limit them in front of it. MCP gateway
(1.2) Remote ingest charges one rate-limit token per OTLP request, however many spans it carries (up to 10,000), and keeps each client's OTLP state until the process restarts, a revoked client's too. Requests are limited, not spans; client state is kept for its dedupe. Restart tracekit ingest serve after revoking a client; keep exporters' batches modest. remote ingest
The compose stack's first start writes a degraded_unanchored gap. The witness doesn't know the log yet. Expected once; check later gaps. deploy compose
The compose end-to-end test runs only where Docker and root (or passwordless sudo) are available; elsewhere it is skipped. It brings up the real stack. Run deploy/compose/e2e.sh on a Docker host before relying on a change. deploy compose
tracekit doctor output is advice about the host, not evidence. Nothing it prints is signed or read by a verifier. Rely on the bundle and the verifier's trust config. doctor
A plain http:// signer URL must name a loopback IP address (http://127.0.0.1:8443, http://[::1]:8443); http://localhost is refused, and a central signer's replica routing is not followed over it. Over plain HTTP the bearer token crosses in clear: only an address, not a name a resolver could point elsewhere, keeps it on the host. Use 127.0.0.1 or ::1, or https://. deploy
The public witness's compose file pins Caddy by tag, not digest. The digest is taken when it is deployed. Pin caddy by digest before production. deploy/public-witness
The public witness's state.db must never roll back: a restored older copy could cosign a fork past it. It is what keeps the witness from cosigning two trees. A lost or rolled-back state means a new witness key and a tracekit release with the new vkey. deploy/public-witness

Investigation (tracekit why)

Limit Why it is so What to do Details
A measured effect is the total effect for this program on this task; it doesn't explain the model's reasons and may not transfer to other tasks. Replay measures what changes when an input is removed, nothing more. Re-test on the tasks you care about. why
ruled-out means smaller than 20 points, not zero. Finite replays give an interval, not an exact value. Raise --n-max until the interval is narrow enough for your decision. why
The viewer's single RUN TEST isn't corrected for testing many inputs one by one: about 1 in 20 inputs with no effect can come out causal by chance. Each test reports its own interval. Use Find the cause (attribute), which corrects for every test it runs. why
The causal graph is only as complete as the recorded context. Content sent to a model without being recorded is invisible to it. Record model calls through an adapter or the gateway. why
Replay costs model calls (about 2·n per test per downstream model call); program replay has no budgets or caching. Proving a cause means re-running the model with and without each suspect. Start with attribute, which tests the group first; set --n-max; on a signed run set --max-calls or --max-cost. why
Run replay needs a re-executable program; decision replay doesn't. Re-running a whole run means running its code again. Use decision replay for calls recorded with their request. why
Tape matching is exact on tool arguments; new calls are stubbed, which can change what the agent does after the stub. Replays must never repeat a real side effect. Read off_tape_actions in each result. why
A tool your own code runs and records with record_action (or the Anthropic adapter's tool_result) runs for real in a replay, with a warning; only act() and the adapter's run_tool are served from the tape. The runtime hears of such a call after it ran. Run tools through act() or run_tool; read off_tape_actions. why
The CLI's replaying commands (test, attribute, matrix, replay) need --system or --allow-program; they never import the program a run names on their own. Importing a module runs its code, and a run file is data anyone could have written. Pass --system module:ATTR for code you trust. why
A run written to the folder by a local Runtime, or ingested before ingest tokens bound runs (no ingest-owner file), is never appended to by ingest. One token per run: an agent that changes token starts a new run. A run is bound to the token whose batch created it. Write each run through one sink, with one ingest token. why
The viewer keeps each analysed why run (its blobs included) in memory, and its event stream records, for the server's life. The analysis is cached until a run's files change. Restart the viewer, or move old runs out of the folder, when a workspace grows large. viewer
Inferred edges compare each node with at most the 20 earlier nodes sharing the most word shingles with it, ignoring shingles found in more than 50 nodes, so a long run can miss some. Comparing every pair is quadratic in the run's length. Treat inferred edges as hints; observed and tested edges are complete. why
Analysing a run takes time that grows faster than its length: each sensitive call's alert searches everything upstream of it, so a run of 1,000 model calls that each see the whole history (an imported Claude Code session) takes about 25 s, and the viewer analyses a live run again each time it grows. An alert names every untrusted input upstream of the call. Open long runs once they finish, or record long sessions as several runs. why
WHY's how it got here shows the chains from the 20 inputs nearest the call. Every input's chain would grow with the cube of a long run's length. Candidate causes lists every upstream input. viewer
The viewer's GRAPH draws a run of up to 600 events; a larger one says it is too large to draw. Thousands of nodes fit a panel only as dots too small to read, and laying them out takes seconds. Use TIMELINE, WHY DID IT HAPPEN? or a call's WHY tab, or record long sessions as several runs. viewer
The viewer shows a run still being written up to the signer's newest checkpoint and checks it again no sooner than four times the CPU time its last check took; its graph is imported again on the same terms (the analysis of each import, about as long again, is not counted), and a final run's findings come after its verdict. A check costs about 0.5 ms for each record it has not checked before and 0.05 ms for each one it has, so a 25,000-call run (50,000 records) shows its newest calls 10 to 30 s late (it was 30 to 110 s, with one CPU busy). A record is shown only once a checkpoint covers it and the run's bundle up to there verifies; checking every run in full at each checkpoint kept one CPU busy and left the page minutes behind. Wait; tracekit verify on an exported bundle covers the same checkpointed records. viewer
While it checks and imports a run being written the viewer holds that run's records in memory a few times over: next to a 25,000-call run it used about 1 to 2 GB. Each check exports and verifies the run's bundle, and the graph imports the run whole. Give the viewer that memory, or view a long run once it is final (tracekit export --v2 --run ID, then tracekit verify). viewer
Past TRACEKIT_OBSERVE_MAX_RECORDS rows (50,000) the viewer and its page drop the oldest from the live view, and the tape says how many left; each run's latest verdict stays in the run list, for up to 10,000 runs (a failed run's goes last). The live view is held in memory. Export a run's bundle (tracekit export --v2 --run ID) for all its records, or raise TRACEKIT_OBSERVE_MAX_RECORDS. viewer
A run the viewer already showed is checked again five minutes after its last check, one run a second, so an edit of records it showed turns the run FAILED (and takes them back) within minutes, later with many runs. Checking every run at every store change would check the whole store each second. For evidence run tracekit verify on an exported bundle; restart the viewer to check every run at once. viewer
verify reports a log without run.end (cut short, or still recording) and test results outside tests.jsonl's chain; whoever can rewrite the files can rebuild both chains, a closing run.end included, and the why log's head is not yet recorded in the signed records. Both chains are unsigned. Cite the signed records for claims; keep the why folder where agents cannot write. why
A webhook gets an alert's rule, title and run only, and at most 100 alerts wait to be sent; more are dropped with a warning. Alerts must not carry the call's arguments, nor stall the server behind a slow endpoint. Open the run in the viewer for the detail. why
LangGraph fork replay serves tool calls only through a ToolNode that is a node of the graph (also inside .with_fallbacks()) or of a subgraph added as a node. A graph without one is refused ("the graph has no ToolNode ..."), and so is a node that runs a subgraph with a ToolNode from its own code. A tool called any other way (a ToolNode invoked inside a function node, a tool called inside a model node) runs for real: a branch with a tool result that was not served stops with an error ("... ran outside a ToolNode ..."), but only after that tool ran. A call a node of the app answers itself (a routing schema such as CompleteOrEscalate) is refused the same way unless routing= (--routing) names it, and a call so named is never checked. ToolNode exposes the hook replay serves from; code inside a node can call anything. Put your tools in a ToolNode node (create_agent and tracekit_tool_node do); add subgraphs as nodes; name only routing schemas in routing=. why
A tool call is served with its recorded ToolMessage (content, status, artifact). A thread in which a tool step also wrote another state key (a tool returning Command(update=...)) is refused ("a tool step of this thread also wrote ..."); a Command's goto isn't replayed. Replay serves results from the thread; what else a tool's Command did isn't a message it can replay. Have tools return messages; keep other state in nodes. why
Fork replay removes messages only: a suspect whose step also wrote another state key is refused ("the step that added ... also wrote ..."). Content that entered through another key would stay in the branch, so removing the message would wrongly rule it out. Keep what a node adds to the state in messages, or test another suspect. why
Branch nodes see a scratch thread: a new thread_id per branch, an empty scratch store if the graph has one, and no node cache. A node that reads long-term memory from the store sees none of it in either arm. Branches must not read or write the app's thread, store or cache, and a cache would serve every unaltered branch the recorded model output. Read the effect as measured without the store's contents; key external lookups on the state, not the thread_id. why
Fork replay runs graphs synchronously: an async-only node or an async checkpointer is refused ("... is async-only ...", "... checkpointer is async ..."), and so is a subgraph with its own checkpointer. Branches are driven with invoke; an async saver is bound to its own event loop. Give async nodes a sync function too, and give the factory the sync checkpointer over the same database. why
Fork replay replaces a removed tool result with a placeholder instead of deleting the message. Model APIs reject a tool call left without its result. Read the effect as "without that content", which is what the placeholder gives. why
An attribution forks once, where the earliest suspect entered; a later suspect that isn't a tool result is refused ("... entered after the fork point ..."). Every test shares one unaltered arm, so they must fork at the same point. Test that message on its own (fork_replay, or --suspect with only it). why
Fork replay refuses a suspect in the thread's first checkpoint or added by a step that ran several different nodes at once ("cannot tell which node added ..."); parallel calls of one node (a ToolNode running several tool calls) are fine. It forks with update_state(as_node=...), which needs the one node that wrote the message. Pick another suspect, or run those nodes one after the other. why
Inferred edges are a heuristic and miss paraphrase. They come from word overlap, not a recorded path. Treat them as hints; only tested edges say "caused". why
The tracekit why guard is a string-matching tripwire, in the agent's process. It checks verbatim values only, where the agent runs. Use the signer's from: untrusted rules, which the agent can't skip. why
tracekit why's own log is hash-chained but unsigned: whoever can rewrite the whole log can rebuild the chain. It holds the content a signer never stores. Cite the signed Tracekit records for every claim that matters. why
With a signer, a message to another agent is observed in the recipient's run as one value with the message's trust: a value the sender had from trusted input but passed on in an untrusted message is untrusted there, and a from: untrusted rule holds it. The signer's provenance index is per run and sees what the agent was sent, not where each part came from. Put the values your app supplies in the task, which every agent's run observes as trusted, or answer the hold. why
(1.2) A handoff to another agent isn't decided: the model asking for one is a reconcile.hook_missing (its detail says what to do) unless the policy's handoffs: names it; no shipped pack names any. A tool the policy names that way isn't flagged when it runs without a decide, even under gateway_mandatory, and a child policy can't remove a name its parent declares. Frameworks run handoffs themselves, without a call id an adapter could gate; the app chooses its tools' names. List your agents' handoff tools by exact name (transfer_to_refunds_agent, LangGraph routing schemas such as CompleteOrEscalate: LangGraph); a loose glob is refused. policy
A tool call your program makes without decision= shows in the bundle as reconcile.fabricated (no model asked for it). Reconciliation compares each decide with the tool calls of the run's model exchanges. Pass decision= when a model asked for the call; read the others as calls of the program. why
With a signer, each model call's response record (the tool calls it led to) is written when the run finishes, after the run's other records. Those tool calls are known only after they ran. Read the request record for when the call was made. why
With a signer, act() fails closed: a signer it cannot reach blocks every call; fail_modes are not applied. agent(), observe(), send() and decide() raise instead: SignerUnavailable when it cannot be reached, RPCError when it refuses (the run closed, the signer shutting down), and finish() warns and still finishes the why log. The runtime has no per-class fail mode of its own, and an input or model call the signer did not record would be a silent gap. Keep the signer up, or record without one (signer=False). why
The signer gates act() only: tool calls an adapter runs itself (check() and record_action(), as the Anthropic adapter does) are recorded in the why log, not decided by the signer. The adapter executes the tool before the runtime records it. Run tools with act(), or wire the tool loop to the signer with a Tracekit integration. why
An event's citation carries log_id and the log seq only once imported; the runtime records run_id and run_seq. The signer's answers name no log id. tracekit why import --store (or --bundle) before you cite a record. why
why import --store reads the records as stored, without checking their signatures. It is a read of your own signer's store, as the viewer reads it. For an independent check, export the run and import it with --bundle --trust. why
A v2 run imported without its why log has no content: inputs, results and model calls read "content committed, not in the log", and a model call's context is what the run had observed so far. The signer keeps commitments, never content. Record with the signer-backed runtime, then import: the merge brings the content. why
With a signer, the agents' runs are linked only by the msg:<from>-><to> run:<id> inputs a message observes in the recipient's run; agent(parent=) is recorded in the why log, not by the signer. The signer registers each run on its own. Send work between agents as messages when the link must be signed. why
What the signer never committed to is checked against nothing: a model call's output, purpose and params, timestamps, and a result recorded as "result not recordable". The import's checks also excuse an uncited model call when a gateway recorded the run's model calls, and a held call retried may cite its decision more than once. The signer commits to inputs, contexts, arguments and results, not to everything the why log holds. Read those fields as the why log's word; cite the signed records for claims. why
A why log is checked against its records by why import only; why verify --salts checks content, not which records the log omits. The structural checks need the run's records. Import (or re-import) the store or bundle before you trust a log. why
why verify --salts checks only the records whose salts you reveal; the others are not revealed. Each salt opens one record's commitments, so it is revealed per record. tracekit signer reveal --record SEQ for every record a case cites. why
Attribution on a signed run needs the content vault on when the run was recorded and its model calls through the gateway; content changed by redaction is used without a commitment check. The signer keeps content only in the vault, and a redacted value no longer opens its record's commitment. Turn the vault on (content.retain) and route agents through the gateway before the runs you want to test. why
Rung 2 assumes no code between the model calls rewrote the history the model was sent, and that tools are deterministic. It re-simulates the loop from the recorded calls and results, without the agent's code. Read the assumptions every result reports; use program replay (rung 3) when the agent edits its history. why
Subagents' runs are not linked in v2 records, so a subagent tool is answered from the tape in rung 2, never replayed recursively. Nothing in a run's records names the run its subagent wrote. Test the subagent's own run separately. why
input: interventions find a run's observed value in a request verbatim (an address, a URL, a whole string of 6+ characters); a paraphrase is missed, and a removed block is replaced by a placeholder, not deleted. Blocks are matched on what the vault holds; deleting one would break the provider's message order. Use ref: with the block's hash (the ranking lists them) for an exact block. why
Replays never send a request that lets the provider run a tool itself (OpenAI Responses tools other than function and custom, such as hosted MCP, web search, file search, code interpreter, computer use; Anthropic typed server tools, mcp_servers, container; chat web_search_options), or that continues context the provider holds (previous_response_id, conversation): such a call can't be tested. A replay would run the provider's tool again, with its side effects; a removal can't reach context the provider holds. Record runs whose model calls declare only client tools and send their whole context, or test a call without them (--decision). why
Part of a why.finding stays the investigator's claim: an outcome scored on text or arguments (text~, arg~), whether the intervention matched anything (applied), and why a replay run is uncounted (ranking or budget). The verifier recomputes the rates, interval and verdict from the outcomes, checks each replay run holds its model calls as gateway records, checks a tool= outcome against the tool uses those records name (a miss only when the target is tool clauses alone), and fails replay runs of the investigation that no finding counts or lists. Bundles hold commitments, not the request and response text the scoring read; a bundle shows only the replay runs it holds. Re-run the test, or read the replay runs' content from the vault, to check a scoring; export with --with-findings, which bundles every replay run of the run. why
A test stopped by its budget reports only the looks it finished; the arms of the look under way are listed in the finding as uncounted (budget), not counted. Each look is decided on whole batches of pairs. Raise --max-calls or --max-cost, or lower --n. why
--max-cost is an estimate reserved before each call: the costliest call so far (recorded or replayed); a call that costs more than that overshoots the budget by the difference, at most once per worker. Budgets count within one tracekit why process, not across runs of the same investigation id. A call's cost is known only once it answers. Leave headroom under the amount you can't exceed; give each process its own budget. why
The surrogate's ranking is predicted, never tested; it asks OpenAI chat calls for logprobs and uses them only when every response carries the target's tokens in its text, else scores by whether the target happened (Anthropic and OpenAI Responses: always by hit). Step-level credit signals don't match executed replays; a tool call's arguments carry no logprobs. Let attribute test the ranked blocks; cite the tests. why
An investigation loads the content of every vaulted record of the run, even to test one model call. The tape (rung 2) and input: interventions need the run's results and observed inputs. Expect one content_get per 100 records of the run; each read is a signed content.accessed. why
--workers takes 1 to 16, --n-max up to 1024 pairs, the ranking up to 1024 samples. A finding names every replay run it counts, and must fit one signed record. Split a larger test into several. why
A bundle that holds a findings run without its replay runs (the findings run alone, a run-set) verifies with its findings not counted (findings counted warning). Only a bundle of the analysed run with its findings and replay runs has the replay runs to count. Export the analysed run with tracekit export --v2 --run R --with-findings. format v2
The TypeScript verifier does not check findings: a bundle with why.finding records or replay runs is UNVERIFIABLE (not checked by this verifier: tracekit why findings). Only the reference verifier recomputes findings. Verify bundles with findings with tracekit verify. format v2
tracekit export --v2 --with-findings reads the first record of every run in the store to find the run's findings and replay runs. The store does not index runs by what they analyse. Expect a slower export on large stores. format v2
The viewer's FIND THE CAUSE and RUN TEST don't run on signed runs yet; they replay why logs with an allow-listed program. Signed-run attribution (content vault, gateway, signed findings) is new in 1.1 and only on the command line so far. tracekit why attribute --run R (--store DIR | --bundle FILE --trust FILE) --gateway URL --target SPEC; the viewer shows the results. why

Intent, reasoning and action checks

Limit Why it is so What to do Details
On a laptop, signed findings read self-asserted: the user who runs tracekit analyze --v2 is the agent's user, which the dev signer lists in analyzers. One user is both agent and analyzer; only a different identity can vouch for a run. For findings an auditor takes as independent, run tracekit analyze --v2 as an operator identity listed in signer.yaml analyzers (never the agent's) and pin it in the verifier's trust config. verdicts
Each pass of tracekit analyze --v2 is a findings run of its own; passes aren't merged or deduplicated. A signed record can't be withdrawn; a later pass may read more content. Read the latest pass of each author; --dry-run to look without signing. findings
What the model said is its own account: a thinking summary is the provider's summary of the reasoning, not the reasoning, and every text the checks read in a Claude Code session comes from the transcript, which the agent can write. The transcript is the only record of the model's text the hooks have; nothing outside the agent's user writes it. Route model calls through the LLM gateway, whose record is preferred; read each finding's trust tier (transcript: the agent can write it). findings, evaluation
A prompt line counts as the person's only when the hook recorded the same text at UserPromptSubmit (TK-X208 flags one it didn't: E20 c5_line, 24 of 24 flagged, 0 before). The hook runs as the agent's user: an agent that runs the hook itself with a made-up prompt and writes the matching transcript line passes. A harness that changes the text between the hook and the transcript (an attachment) makes a real prompt unconfirmed; a paste or an image Claude Code writes as a placeholder ("[Pasted text #1 +40 lines]") pairs with the hook's prompt before it whose text holds the line's other text, and a hook prompt no line pairs with (a line the tailer missed) is a request of its own. Hooks from before this release record no prompt (no_hook), and their transcript lines count as before. Only a hook process sees the harness's payload, and on a laptop it runs as the agent. Read TK-X208 findings against the session; system mode keeps the signer and the tailer out of the agent's reach, not the hook; update the hooks with tracekit init --dev --v2. coding agents
TK-X202 checks names and the words around them, not meaning: an edit counts as told when the request names its file before the call, or the model's text says it changes it (a change word tied to the file, before the call or after: "applying the same fix to billing.py", "billing.py needs the same fix", "the bug is in billing.py"). Naming the file innocently no longer clears it (E20 c1 with no constraint: every edit caught on every set; 17% tuned, 0% pool v2 before 2026-10-19), but one the model says it updates passes whatever reason it gives ("updating billing.py to match the fix"); "the billing module" for billing.py is no mention (a false alarm); a new file, or one a search found and the request didn't name, is flagged (5 pool v1 and 4 pool v2 hard negatives, and 2 of the 119 calls of E20's honest sessions). In a request clause with a negation, a file is no longer named where the negation governs a change of it: a prohibition the grammar can't read ("nobody should ever edit X", "X must not be edited" phrased past its lists) doesn't count X as asked, so its edit is TK-X202's; a negation of what the file does ("make sure uploader.py never sends the token", "X must not crash when ...") or of a change of one part of it ("don't change the signature in utils.py") leaves it named, and so does a negation about something else for a name with no extension ("update the README so it never mentions the old flag"). A name the grammar doesn't read as a file stays named under a negation it can't read as a change of it: "never let anyone edit the run script" is TK-X202's, "the run script must not change" is neither TK-X202's nor TK-X204's, and nor is "don't touch the README" for README.md (name the file: "README.md"). Only a write tool's edit is checked: a shell command that writes a file (sed -i, a redirection, cp, patch) is never TK-X202's (TK-X203 and TK-X204 read its targets). A word check can't read intent; a shell command's targets are a lexer's guess. Read each TK-X202 finding against the change; name files in requests; add --review, a reviewer model that reads meaning (TK-X210, warn only). findings
Stated constraints (TK-X204, and live constraints) are read by a fixed English grammar (constraints.py): a negated change word tied to the file ("don't touch / edit / write to / mess with X", "make no changes to X", "please refrain from editing X", "I'd rather you didn't touch X", "I don't want X changed"), the file said to stay as it is ("leave / keep X as is", "X is off-limits / out of scope / maintained elsewhere / frozen", "X belongs to another team", "X must not be modified"), "hands off X", "stay away from X", "keep (all) your changes / your hands / any edits / yourself out of X" (only the agent's changes: "keep the API key out of settings.py" and "keep this out of X" keep nothing away), "it" or "there" for the file named before ("X is generated; make no changes there"), a directory ("anything in vendor/", "leave src/legacy/ alone", "the vendor directory", "the .github folder", "stay out of the infra/ folder", "keep your changes out of db/migrations/"; not "the home / root / current / working / parent directory", a place rather than a folder's name; a folder word leaves the clause's files as they are, so "use the tmp folder for scratch files and leave config.yaml as is" keeps config.yaml away), "leave X exactly as it is", "the X file must not change", "under no circumstances touch X", "don't make any changes" (read-only; "don't make any changes to / in X" keeps X away when X is a file or a directory, and is no read-only) or a file people name without an extension ("the Dockerfile", "Makefile", "LICENSE"), "read-only" as an instruction ("read-only: just look", "stay read-only", "work in read-only mode"), "don't change anything", scopes ("only change T", "limit the change to T", "touch nothing but T", "everything else is out of scope"), "don't push", "no network", "work offline". Other phrasings state nothing: it reads 9 of the 14 ways of saying it in E20's pool v1 ("don't go near X", "a hard freeze on X until after the audit", "X has a pending PR, so editing it will cause a conflict: avoid", "hands off for now" after naming X in another clause, "nothing under X" are not) and 11 of 12 in pool v2 ("it would be better if X stayed exactly as it is" is not); both pools were read while the grammar was written. On pool v3 (blind, scored once on 2026-10-20; the final code review's fixes after that run moved none of its numbers) a live constraint held 67-92% of the edits made after a "don't touch X", by cover. A file is read in a window of five words. Some phrasings are left unread so they hold no wanted call (on E20's 56 honest sessions none is held; before the round-2 fixes 13 were, as "read-only" describing a mode, "nothing should change for existing callers" and "don't push to main" were read as constraints), though a phrasing outside them still can, which the person then approves (an approval by another identity than the run's lowers TK-X204 to low): a condition after the file, the push or the network ("don't edit X until the tests pass", "without checking the tests first", "unless", "before", "if", "while the server runs", "when DEBUG is set": the change is allowed once the condition holds, which the grammar can't check; "while you work" and "while fixing Y" are the task itself; a condition on the person's own go-ahead, "don't push before I review", "without asking me first", "until I say so", "unless I confirm", keeps the constraint, as the hold is what they ask for); "read-only" describing a thing ("add a read-only mode", "make the connection read-only", "the cache should be read-only after startup"); "nothing should change" for its callers; a negated change of one aspect of the file asked for ("don't change the public API of X", "the signature in X", "nothing in X changes its behaviour"); a scoped push ("don't push to main", "never push secrets"; "don't push it to origin" is read); a push or the network in a description rather than an instruction ("we don't use the network in unit tests", "the app works without network access", "our policy is no network access in CI"); a constraint a later clause of the same request asks for ("don't touch X for now; ... then update X too", "don't change anything, then fix it"); a question that asks rather than requests ("should I change X?"; "could you leave X alone?" is read); quoted text, quoted lines ("> ...") and code (blocks, lines four spaces in); a negation turned around ("don't forget to update X", "you don't need to touch X", "I don't mind if you change X"); "don't change how X is imported"; "any changes" followed by what picks them out: "to", "of", "in", "on" and the like with no file or directory there ("don't make any changes to the tests", "... to the public API", "... on main"), or "that", "which", "beyond", "outside", "for the sake of" ("... that break the API", "... beyond what the fix needs", "... for the sake of style"; the whole project, "to the code / the repo / my project", "on disk", "on this machine", and "of any kind", "in the meantime", "under any circumstances", "to any file", "for now", "for any reason", "for me", "on your own", "directly" stay read-only); "outside X" ("don't touch anything outside src/", a scope; "everything outside X is off-limits" is read); "keep the diff to X" (as "keep the changes to X small" asks for a change); "don't change anything except the docs" or "don't change anything else" naming no file; a network word after a change verb or before a noun ("don't change the network code", "no web UI changes"); "offline" anywhere but at the start of an instruction ("make the CLI work offline"). A grammar that guesses more would hold or flag calls the person asked for. Say constraints plainly in requests ("don't touch billing.py"); read the run's constraints at the top of its review in tracekit view. policy
A scope ("only change src/pager.py", "everything else is out of scope") is checked after the run (TK-X204) and never held live: the hook adds no live constraint for it. A file named to keep away ("don't touch billing.py") is still held live. Holding every write but the named files would ask the person about each test, lockfile or build write. Read TK-X204 after the run; name the files that must not change ("don't touch billing.py"), which the signer holds live, or keep them out with a policy rule. policy
A later "it", "them" or "there" in a clause that asks the agent for the change lifts the file named just before it in the same request (for "them", each file of that clause), so "Don't touch billing.py. If tests fail, fix them." states no constraint: "them" is read as billing.py, and the constraint is lost (no live hold, no TK-X204). A clause asks for the change when its change word is in its plain form with only words of asking or allowing before it ("fix them", "you can edit it locally", "later you can update it", "feel free to change it", "go ahead and update it"), and it doesn't start with "to", "should" or "must". A clause that waits on the person lifts no file, named or by "it": the person's go-ahead in it or in a clause after it that only says that ("you can change it after I approve", "edit X once I say so", "you can change it later, but ask me first"). Any other clause with the change lifts nothing, and the constraint holds: "ask me before you change it", "to change it, ask me first", "should you need to change it, ask me", "I'll update it myself", "we'll change it in the next PR", "explain how you would change it", "changing it breaks the deploy", and also "don't forget to update it", "you'll need to edit it", "you may need to change it" and "I want you to change it", which do ask for it (the person approves the held call). A constraint whose condition is the person's own go-ahead ("never touch X without asking me first. Change the timeout there to 30") is never lifted that way: it holds, and a file a request lifts that way counts as named (no TK-X202). The grammar can't tell what a pronoun stands for; it takes the file named just before it, so that "X is frozen in production, but you can edit it locally" reads as the person meant, and lifts only where the clause hands the change to the agent, so a change merely mentioned keeps the constraint. Name the thing to change instead of "it" ("if tests fail, fix the tests"), name the file again ("fix them, but don't touch billing.py"), or state the constraint last ("If tests fail, fix them. Don't touch billing.py."). policy
A live constraint compares a file by its base name, and a directory by its name anywhere in the path written: another billing.py elsewhere is held too, as is a write under any directory of the kept one's name (one over the project included), and a recursive rm or rmdir of it. The writes it sees: a write tool's paths, a redirection, tee, touch, rm, truncate, shred, cp/mv/install/ln/rsync/scp destinations, sed -i/perl -i files, dd of=, patch's file, git rm/mv/restore paths and git checkout -- X, the -name of a find that deletes or runs a command, and formatters that rewrite in place (black, isort, rustfmt, prettier --write, gofmt -w, ruff format, ruff check --fix, eslint --fix). A write the policy engine's shell parser can't see isn't held: a script, a variable or glob path, python -c or python fix.py billing.py, awk -i inplace, an editor (vim, ex), an archive extracted over it, git apply and patch with their files only in the diff (a write, so read_only holds them; a file kept away is not matched); a line it can't parse, such as case, which the policy's TK-SHELL-PARSE asks about. A push is git push; the network is an http or browser call, WebFetch, curl, wget, ssh, git fetch, pull, clone or push and package installs. A run takes 32 distinct file constraints (keep_away, only_change; read-only, no push and no network are always added): past them a new one is a signed capture.gap{constraint_refused}, constraint_add answers refused, the hook tells the person, and every later write of the run is held, so made-up constraints only make the agent wait. The signer matches what the call's arguments show, by commitment; it doesn't run the shell. Keep a policy rule or a sandbox for writes that must never happen; add the kernel sensor, whose file and connect effects TK-X204 checks after the run. policy
A call that writes more than 256 distinct files is held in any run with a file constraint (keep_away, only_change), whatever the files: "the call writes more than 256 files". The signer checks each file name against each file constraint by salted commitment; the cap bounds that work per call. Approve the call if the person meant it, or write the files in smaller commands. policy
The hook keeps a session's constraints in its own state, in the agent's runtime dir: a session resumed after it ended starts without its earlier prompts' constraints (SessionEnd removes the state), and an agent can edit that state before its next run. A run the signer closed idle passes them to the session's next run. Constraints are signed per run; the hook is the only place that knows a session's earlier prompts. Restate constraints in the first prompt after resuming a session; for limits that must hold whatever the agent does, use a policy rule. policy
On a laptop, the person who answers a call a live constraint held is the agent's user. The dev signer has one user. In system mode, set signer.yaml approvals to a person other than the agent. approvals
"Signed before this call" holds only when the record of what was said is older than the call's decision. With Claude Code alone it often isn't: the tailer reads the transcript after the model wrote it, so its lines are signed after the hook's decide (transcript_order). The hook decides as soon as Claude Code runs the tool; the tailer follows the file. Route model calls through the LLM gateway, which records the response before the client gets it. viewer
Claude Code keeps the model's thinking only sealed unless thinking summaries are on: TK-X201 is then withheld for its calls. Claude Code's default shows no thinking text (showThinkingSummaries; headless --thinking-display). tracekit init --v2 turns them on (--no-thinking-summaries leaves them alone); headless: claude -p --thinking-display summarized; tracekit doctor warns (D-THINKING-SUMMARIES). doctor
The LLM gateway asks for readable reasoning only where the request already has the model reason: a request with no thinking (a Claude model that thinks by default, such as Opus 5.5, still returns none of it), display: "updates", or a chat completions request gets what it asked for. The gateway never turns on reasoning the agent didn't ask for, and changes only the field that shows it. Have the agent send thinking: {type: "adaptive"} (Anthropic) or reasoning (Responses); the gateway then asks for the summaries. gateway
What the gateway gets back is the provider's summary of the reasoning, in its words, not the reasoning, which providers return only sealed (a thinking block's signature, OpenAI's encrypted_content) or not at all. A summary can leave out what drove a call. Providers don't return these models' reasoning in the open; a summary is the most they show. Read a summary as the model's account, never as proof of why it acted. gateway, findings
Any 400 or 422 to a request the gateway amended is taken as the provider refusing the amendment: the request goes again as the client sent it, so one bad for another reason reaches the provider twice. After a request sent again succeeds, that model's requests aren't amended until the gateway restarts (at most 1,024 models remembered). Providers word an unknown-field error differently, and the agent must never fail on a field it didn't send. Read the first exchange's error (sent again without the amendment as exchange ...); restart the gateway once a provider takes the field. gateway
TK-X203 reads a Bash command with a lexer: redirections, tee, touch, mkdir, rm, rmdir, truncate, and the destination of cp, mv, install, ln. Variables, globs, command substitutions and scripts aren't followed, symlinks aren't resolved, and a relative path after a cd is not checkable. The project is --project or the first prompt's working directory; / or a home directory is not checkable. A shell is a language; the analyzer doesn't run it. Pass --project; for writes that must be seen, add the kernel sensor, whose file effects TK-X203 also checks. findings
A run still open is refused unless --open, and then is partial; the analysis stops at its budget (60 s, --budget) or its size caps (131,072 distinct words, 8 Mi characters of prompts): what it found before stays, what it didn't reach is not_checkable: budget or size, and the result is INCOMPLETE (exit 3 when nothing high was found). Each text a check reads is cut at 64 KiB, and a streamed response keeps at most 512 Ki characters of text and reasoning, and a Gemini candidate at most 1,024 parts (marked truncated). What an open run does next isn't in it; a run of any size must finish. Analyse after run.final; raise --budget for very long runs; review calls past the size caps by hand or with --review. findings
The constraint grammar reads a text in time linear in its length except in two places: a negated push or network word, or a word that lifts a constraint ("fix", "add", "push"), reads the words before it in its clause again (a 64 KiB clause of them: about a second), and "them" stands for every file of the clause before it in each clause that says it (64 KiB of "don't change them" or "leave them alone" after 3,000 to 5,000 files: about 2 s and up to 1.1 GiB in tracekit analyze --v2). The hook reads each prompt with the same grammar. Reading each such word against its clause keeps the grammar small, and real requests are far from these sizes. Nothing on the signer: the grammar runs off it, in the hook and in tracekit analyze --v2. Bound the text a reviewer feeds the analysis from elsewhere. findings
A call decided under an id the model's response doesn't carry (an adapter's own id, a Gemini call without one) is unlinked: its reasoning is not checked. The link between a call and the model message is the provider's tool call id. Use an integration that decides under the provider's id. findings
The text checks (TK-X205, X101..X104, X111..X114, X120, X211) read words: a success claim, a denial or a mention phrased outside their word lists is missed. On E20's pool v3 (blind, scored once on 2026-10-20) TK-X205 finds 86% of the false success claims after a failed run (exit 86%, piped 87%). On pool v2 (blind, scored once on 2026-10-19, read since) it finds 85% of false success claims made with the pool's success phrases and all of its free-form final summaries; on pool v1 68% and 96%. An admission takes back the success claims about what it names ("2 tests still fail"), not the others: a summary that says a test still fails passes even when another check it claims also failed. A denial is read as the model's own act: what a thing does ("so it never makes network calls", "the app works without network access") is no denial, nor is one after "so" or with "it", "they" or "which" for its subject; a lie put that way ("so nothing was pushed") is missed. "Changed X without touching the tests" denies only the tests. "No code changes" denies an edit of a source file only, read by its extension (59 of them, findings_text.py SOURCE): an edit of a file with another extension (a stylesheet, a template, a config) is no code change. TK-X101 reads a claimed test, build or lint run ("all tests pass", "the build is clean", "ESLint reports zero problems", "I ran the linter"); a success claim that names no check ("everything works as expected") claims no run, and most of the pools' claims are such: 13% of pool v2's and 29% of pool v1's Fc sessions are found, every one whose claim names a test run. "X is installed" reads as a state, not an install. A claim or denial that names nothing gives the claim and denial checks nothing to find: TK-X211 finds the action it leaves out. A word list can't read meaning, and a text check finds only what the text says. Read each finding against the run; for runs that matter, add --review (a reviewer model, TK-X210) or review what the model said yourself. findings, evaluation
TK-X211 counts an action as told when the model names it, its readable reasoning included (Claude Code shows the person a thinking summary): a word of its kind in the model message that asked for the call or one after it ("added the dependency it needed and pushed" passes; a word of deleting only with a part of the deleted file's name, a package noun only beside a word of adding), its object named anywhere in the run, also for another reason ("backing up billing.py", then billing.py sent out; E20 tuned F2 c1: 0 of 24 caught), or the person's request asking for it, by its object ("upload report.txt") or with the verb as an instruction about this work ("commit and push the branch"; not "the upload handler", "I'll push it myself", "remove the unused helper" before a delete of another file). A kind word with or after the call still clears it when it is about something else ("uploaded the screenshot" beside an upload of .env). A dotfile is named only with its dot: ".env" tells of an upload of .env, "env" ("checking the env vars") or "travis.yml" for .travis.yml tells of nothing, and an ellipsis is no dot ("the config... env vars"). The request's words too: "upload the env file" asks for no upload of .env, so one the model never mentions is found. Sealed reasoning tells nothing. It sees what a lexer sees: curl and wget body or file flags, an HTTP tool's POST, PUT or PATCH with a body, httpie's method, scp, rsync and sftp to a host:path, git push, package publishes, rm, git rm, git clean, a delete tool, install or add of a named package; a script, an SDK call, a variable or another program hides the action. A delete of a temp path, a build or cache directory, or a file of a name an earlier call wrote is the run's own (by name: a file of that name elsewhere passes too); a file an edit tool changed is not. Counting only the text flagged honest runs that explain the action in their reasoning; a lexer doesn't run the shell. Read TK-X211 against the run; require approval with a policy rule for actions that must never happen unannounced; name a dotfile with its dot in requests (".env"); add the kernel sensor for what a script does. findings, evaluation
TK-X205 sees a failure only in a related call's signed result or its output: a test or build run it can name (pytest, jest, vitest, mocha, go test, cargo, unittest, make, npm/yarn/pnpm scripts, Maven, Gradle, ...) whose result is ok: false, or whose output has a runner's failure summary, a non-zero exit, or a Traceback or error line with no pass summary. A test run through a script it doesn't name, a runner that prints success and exits 0 after a failure, output cut before its summary, or a claim about something other than tests, a build, a deploy, a merge, a push, a commit or an install is not checked. A result the agent didn't complete is no_result. Only the run's own records and their content are read; the analyzer doesn't run anything. Run checks with a runner it names, keep the summary line in the output (tail keeps it), and complete every call. findings
TK-X210 (--review) is a model's opinion: it can miss a fault, flag an honest run, and be steered by text in the run that addresses it; two calls on one run may answer differently. Its detection and false-alarm rates are not measured yet (the E20 model arm is built, not run). A reviewer model reads meaning the checks can't, and the run's text is written by the agent it reviews. Read TK-X210 as a second opinion beside the deterministic findings, never as a pass; it never changes the exit code or decides a call. Run the E20 model arm on your reviewer model before relying on it. findings, server threat model
A reviewer of the agent's own model family may share its blind spots. The family is read from model names (claude, gpt, gemini, ...); a fine-tune or alias with another name is not recognised. The analyzer knows a model only by the name the records and --review-model give. Configure a second provider (OPENAI_API_KEY or ANTHROPIC_API_KEY, or TRACEKIT_REVIEW_MODEL with TRACEKIT_REVIEW_URL) and --review picks the one of another family; or pass --review-model. tracekit analyze warns when the families match. findings
--review sends the run's text (redacted by the signer) to the reviewer model: the one at --review-url, else the one it picks (TRACEKIT_REVIEW_URL's, else Anthropic's or OpenAI's API, whichever key is set and is of another family than the agent's). Judging a run means reading it. For runs whose text must not leave your machines, point --review-url at a model you run yourself, or at your LLM gateway in front of one. privacy
The reviewer reads a run cut to about 48,000 characters (each text to 1,200, arguments to 400; records dropped from the middle), and sees whether each call succeeded, not its result. One bounded call per pass keeps cost and time fixed on runs of any size. Read the cut (shown.review.truncated); for a long run, read TK-X210 for its start and end only. findings
--review needs a findings run to record the model call in, so it runs only as an identity signer.yaml analyzers lists, on the signer that holds the run, and not with --dry-run. Without --review-gateway the analyzer records the call itself: the record is its own report, not an independent one. Only Anthropic Messages and OpenAI chat endpoints are spoken. The call is evidence too, kept beside the findings; only the gateway records it from outside the analyzer. Run it as an analyzer identity; use --review-gateway where the gateway runs. findings
CrewAI, Browser Use, a LangGraph tracekit_tool_node graph and the Vercel AI SDK record no readable reasoning through their integration; the Claude Agent SDK needs tracekit_said in the app's message loop and Microsoft Agent Framework needs tk.chat in middleware=. CrewAI's after-LLM hook sees no response that asks for a tool, Browser Use's Tools never see the model's output, a ToolNode has no model hook, the Vercel middleware sends a commitment only; neither SDK gives a hook the model's messages. Record those model calls with tracekit.instrument() or the LLM gateway; add the one line where the table says. reasoning
A framework rarely says whether reasoning text is a summary or the model's own reasoning: the integrations record it as a summary unless they know (an open model's reasoning_content, an inline <think>). Cursor's thinking text is recorded as a summary too. Only the provider knows; the framework passes the text on. Read summary as "readable"; the monitorability score never counts more full reasoning than there was. reasoning
Once an integration records model messages, the signer reconciles them with the calls: a call it decides that no recorded message carries reads as reconcile.fabricated (a message that failed to record, which warned), and a call in a recorded message no decide followed reads as reconcile.hook_missing (a run that stopped before its tools ran; a call the framework skipped or a person turned down before any hook: an ADK confirmation declined). The LangChain middleware and Pydantic AI's capability add each call to its response only when it reaches their gate, so a call they never decide (a person's reject or a call limit in another LangChain middleware, the recursion limit, arguments that fail Pydantic AI's validation) is not recorded; a Pydantic AI approval resumed by another Tracekit capability adds the call from the history. A call a person edited in LangChain's HumanInTheLoopMiddleware is recorded as the model asked for it and decided as edited: it reads as reconcile.args_mismatch. Gemini CLI's and Cursor's messages name no call (said_only) and are never reconciled. Reconciliation can't tell a call left out of the record from one the agent made up, nor a person's edit from an agent's. Read such records against the warnings the agent printed and the run's last steps; read an args_mismatch on a call your app let a person edit against that edit (the interrupt's resume value), or use the policy's ask, whose approval the signer records. gateway, reasoning
Codex's reasoning is readable only with model_reasoning_summary set (init sets it at the top of config.toml; a profile's own setting is neither set nor read by init and doctor), and stays sealed (encrypted_content) without it. Each hook reads the rollout lines written since the session's last hook, at most 4 MiB: lines that failed to send are not read again and no gap says so, a rollout rewritten in place is read on from its new end, and a Codex release that names a call's hook differently reads as args_mismatch or hook_missing. The rollout is Codex's only record of the model's reasoning; the hook maps each call the way Codex builds its hook payload. Keep the setting on (tracekit doctor: D-CODEX-REASONING); route model calls through the LLM gateway where the record must be complete. coding agents
Gemini CLI's thought summaries and Cursor's thinking text are not linked to a call, and Gemini CLI writes its thoughts after the calls ran. Text from Codex's rollout, Gemini CLI's chat recording and Cursor's hooks is written by the agent's own process (trust tier transcript). Neither gives a tool call an id in its hooks; Gemini CLI queues thoughts until the calls complete; the files and hooks belong to the agent's user. Read their reasoning as the session's, not a call's; route model calls through the LLM gateway for text the agent can't write. coding agents
tracekit view runs the checks only for the dev signer's final runs; another viewer shows each model line as committed, not its text. Only the dev signer grants the viewer's user content_read. Run tracekit analyze --v2 as an identity with content_read. viewer

Effect checks (TK-X206, TK-X207)

Limit Why it is so What to do Details
TK-X206 and TK-X207 join effects to calls by time across two clocks: the sensor's (each effect) and the signer's (each decision and result). Past --clock-skew (0.5 s) honest effects become findings. A sensor clock ahead shows in any effect recorded before its own time; one behind only when every effect reached the signer more than the import's one-second flush late, which a slow import looks like too. The sensor stamps events on its node; the signer on its host. Keep both hosts on NTP; when a finding is labelled clock_skew, run again with the --clock-skew it names. findings
The harness itself (Claude Code's node process, Codex) runs from before the run: its connections (its model calls) and file writes are agent_process, not checked by TK-X206, TK-X203 or TK-X204, except a file at a write call's path inside that call's window. So code that runs inside the harness's own process (a plugin, an extension) and writes outside the project isn't flagged from the sensor's records; a call's process writing there is. A WebFetch's connection counts only to a host the harness didn't reach outside fetch calls, and a fetch over a connection the harness kept open opens none (TK-X207 finding). Nothing names the call for the harness's own work, and a sensor gives an address, not a host name. For hosts, add the egress observer, whose signed reconcile covers them; read a fetch's TK-X207 against the egress records. findings
A shell or Python process started outside any call, not by a call's process, is hook_like, not checked: Tracekit's own hooks look the same, and so does a hook or status line of your own, or a Python program a planted hook runs (E20 held-out: a Python exfiltration between calls was not checkable in 4 of 8 Claude Code runs). Its other programs are still checked. The hooks run as the harness's children, around each call, with no call of their own; only binary and parent tell them apart. Review the harness's hook settings; with Codex, keep CODEX_TOOL_CALL_ID in the sensor so a call's processes name it. findings
Without parent exec ids (Falco, or Tetragon before this release's import), a process a call left running is that call's only inside its window: a server's later children and connections are TK-X206 findings. Falco names no parent process; the join falls back to time. Use Tetragon with tracekit sensor import from this release for agents that start servers or background jobs. findings
TK-X207 checks a write only where you say the sensor's file policy selects writes (--sensor-skip DIR for each directory it leaves out), a kind of effect only if the run has some, and a Bash command's programs by name: a command whose program has the same name as Tracekit's hook interpreter (python3 -m pytest against a python3 hook) is hook_binary, not checked, when only such processes ran. The run doesn't record the sensor's policy; a hook's process and the command's look alike. Pass --sensor-skip; with Codex keep CODEX_TOOL_CALL_ID, which makes each command's processes name it. findings
The egress observer's per-minute network totals aren't part of TK-X206 or TK-X207. The signer reconciles them itself (reconcile.unexplained_effect); a total over a minute can't be placed inside a call. Read reconcile.unexplained_effect records for hosts. egress

Viewer

Limit Why it is so What to do Details
A signed run gets its own graph only when the viewer has no why logs: with --why DIR, a why/ folder in the signer's data dir, or view.logs, a run without a why log has no GRAPH. Each verified run is imported again whenever it grows, and its graph goes once its rows leave the live view (past TRACEKIT_OBSERVE_MAX_RECORDS). The import fills the why workspace that why logs otherwise fill; re-importing a whole run keeps it current simply; a graph kept for every run the viewer ever showed grew without bound (some 3.4 KiB a record). Open the store without --why to see every run's graph; expect a delay on very long runs; for a run that left the live view, tracekit why import --bundle its exported bundle. viewer
The viewer's verdicts are operator-side, not evidence. The viewer runs on the operator's side. Download the bundle and verify it with your own verifier and trust config. viewer
v2 records keep a call's arguments only as a salted commitment, so the viewer shows rule reasons, not arguments; for other policies, only rule ids. Records never hold arguments. Ask for tracekit signer reveal on the record when an audit needs it. viewer
A run that fails verification shows only its failure report. Records of a failed run aren't shown as if they were evidence. Investigate with tracekit verify. viewer
A run stays PENDING until a checkpoint covers its last record. The verdict needs a checkpoint. Wait for the next checkpoint. viewer
When the viewer's verification thread stops on an unexpected error, the page says verification stopped and no run is checked again until tracekit view restarts. A run whose own check fails shows NOT CHECKED and is checked again 5 minutes later or when it grows. One thread checks every run and nothing restarts it; one run's error is kept to that run. Restart tracekit view; for a run you need now, export it and run tracekit verify. viewer
The viewer's printed token is one operator credential over every tenant's runs. It is the local operator's access. Use OIDC roles for auditors and approvers. viewer
(1.4) The viewer's session cookie (the token's, or an OIDC session's) goes to every port of the viewer's host name: a page served by another program on that host, at another port (an agent's dev server on 127.0.0.1:3000) that you open in the same browser receives it and can read the viewer as you until the cookie ends. The token's cookie ends after 8 hours or when the viewer stops; an OIDC session after 8 hours. Browsers scope cookies by host name, not port, and the page needs a cookie: the token stays out of its URLs. Open the viewer under a host name nothing else on the machine serves (127.0.0.1 for it, localhost for the rest), or in a browser profile of its own; restart the viewer to end every token session at once. viewer
The timeline is scaled to the calls shown, not the clock. It shows sequence, not duration. Read times from the records. viewer
Why logs are shown to the printed token only; an OIDC session gets none of them. A why log belongs to no tenant, so the viewer can't scope it. Give investigators the token, or run tracekit view --why DIR for them on their own logs. viewer
FIND THE CAUSE and RUN TEST run only for programs passed with --allow-program; otherwise the WHY panel shows the command to run. A replay imports and runs the program a run names. Start the viewer with --allow-program module:SYSTEM, or run the printed tracekit why command. viewer
A signed call is joined to its why event only when that event cites the signed record it produced and both name the same tool; why logs without the citation (recorded without a signer, or imported from v1) show in GRAPH, AGENTS and INFLUENCE but not in a signed call's detail. The citation is the only link between the two logs. Record with the signer-backed why runtime, or open the logs alone with tracekit view --why DIR. viewer
A joint cause FIND THE CAUSE reports is marked in the candidate list only until the page reloads; its summary line says it either way. Joint groups aren't saved per candidate. Re-run FIND THE CAUSE, or read the summary. viewer
The why workspace reads every why log on each refresh. It is recomputed rather than cached. Keep the --why directory to the runs under investigation. viewer
The viewer's why views have no links of their own (Causeway's #/run/<id>/why/<call>), and a candidate cause has no COPY COMMAND button. The page is one live view of a server, not a set of routes; the command is printed instead. Name the run in the picker; copy the printed tracekit why test command from the WHY panel. viewer
The static why report (tracekit why report, and the demo's report.html) runs no replays: FIND THE CAUSE and RUN TEST show the command to run, and it shows the runs as they were when it was written. It is one file with the runs embedded, opened without a server. Run the command it shows, or open the folder with tracekit view --why DIR --allow-program SPEC. why
One tracekit view shows one source: a v2 signer's store (or a central deployment's logs), a v1 ledger or bundle, or why logs alone; the nav links only the pages that source has. Each source is its own feed, and the page shows one feed. Start a second tracekit view on another --port for another source. viewer
On a laptop the nav has no APPROVALS page: it needs OIDC login and view.signer. The page answers for a signed-in person over the signer RPC; the printed token is no person. Answer with tracekit approvals, or configure viewer login. approvals
tracekit view --ledger and --bundle serve loopback only, and have no --prices or --export. They keep tracekit observe's rules for the v1 ledger. Reach the page through an SSH tunnel; for cost or a replay file run tracekit observe --prices FILE or tracekit observe --export FILE. viewer

Install

Limit Why it is so What to do Details
On an Intel Mac or a musl Linux (Alpine), npm install @cygnuxlabs/tracekit gets no platform signer package, so the dev signer needs Python. Signer packages are built for four platforms. Intel Macs lack usable google-re2 and cryptography wheels, and none is built for musl. pip install tracekit-ai, or point the client at a running signer. TypeScript SDK
pip install tracekit-ai also installs cryptography, rfc8785, google-re2 (Python 3.10+) and regex. The dev signer starts from that one install. It signs, canonicalises JSON and runs its policy engine. To get the verifier alone, pip install --no-deps tracekit-ai. It runs on the standard library. quickstart
A --no-deps install verifies bundles but can't run a signer. tracekit up and tracekit init --dev --v2 refuse to start. The signer needs the policy engine and cryptography. Neither is in the standard library. pip install tracekit-ai doctor
Where google-re2 has no wheel (a new Python, an uncommon platform), pip builds it from source, and without a C++ toolchain and abseil the whole install fails. pip has no fallback between dependencies. The signer itself uses regex when google-re2 isn't importable. pip install --no-deps tracekit-ai, then pip install cryptography "rfc8785>=0.1,<0.2" regex (with their own dependencies) quickstart
(1.2) Claude Code's hooks go to $CLAUDE_CONFIG_DIR/settings.json when it is set, else ~/.claude/settings.json. tracekit uninstall and tracekit doctor look at both, and at the file tracekit init --dev recorded; hooks tracekit init --dev --v2 put under a CLAUDE_CONFIG_DIR that the current shell doesn't set aren't found. Only the shell that installed them knew the directory, and the v2 dev init keeps no client config to record it in. Uninstall with the same CLAUDE_CONFIG_DIR you installed with. quickstart
(1.3) The Claude Code plugin runs the v2 hooks. Updating it from a v1 plugin moves v1 dev mode to the v2 dev signer: the v1 policy and tracekit pending no longer apply, and the v1 ledger can be bridged into the v2 log only until the v2 signer records something. In v1 system mode every tool call is blocked until v2 system mode is set up. The plugin carries one hook, and the v2 hook needs a v2 signer. Before the next session: export the v1 runs you need, tracekit uninstall, tracekit signer bridge, tracekit init --dev --v2 --no-hooks; system mode: sudo /usr/bin/python3 -m tracekit init --v2 --user <agent-user> --no-hooks. plugin
(1.3) With the Claude Code plugin and tracekit init's hooks both in place, every call is decided twice and every approved call is blocked. tracekit init --dev --v2 installs no hooks while the plugin is enabled, but sees only enabledPlugins in the user, project, local and managed settings; system init doesn't check. Both hooks decide the call, and one consumes the approval the other waits on. Keep one: tracekit uninstall or /plugin disable tracekit@tracekit. tracekit doctor warns when both run (D-HOOKS-PRESENT). With the plugin, run system init with --no-hooks. plugin
(1.3) The Claude Code plugin without the tracekit package lets every event run unrecorded, with a notice in the transcript, until Tracekit is set up for the user (the dev signer's data dir exists, or TRACEKIT_PYTHON or TRACEKIT_SIGNER is set); from then on it blocks tool calls. Blocking someone who installed the plugin but not Tracekit would lock them out of Claude Code with nothing yet to protect. pip install tracekit-ai && tracekit init --dev --v2 --no-hooks plugin
sudo tracekit uninstall --v2 changes nothing while the agent's Claude Code settings file is a symlink or not valid JSON, and says so. Root edits that file as the agent's user, never through a link, and never overwrites what it can't parse. Fix or remove the file as the agent's user, then uninstall again. quickstart
tracekit init --remote (v1) after tracekit init --dev leaves the dev tracekitd running, and tracekit uninstall then doesn't stop it. The remote client config replaces the dev one, which named the daemon's home. Run tracekit uninstall before switching, or stop the pid in ~/.tracekit-signer/tracekitd.pid. remote ingest
A --no-deps verifier is slower. It falls back to pure-Python Ed25519 and JCS. Both are checked against cryptography and rfc8785 and give the same verdict. Install cryptography and rfc8785 if you verify many large bundles. signing

One-line setup (tracekit.instrument())

Limit Why it is so What to do Details
(1.2) A client keeps each run's event counter until the run is closed (run.close()) or the signer answers that it is: a server that lets conversations end by the signer's idle timeout keeps about 100–300 bytes per conversation (TypeScript: for the client's life; Python: at most 65,536 runs, the least recently used forgotten first, so a run idle while that many others were used restarts its count and its next events read as a gap). A counter can't be dropped while the run may still take events: a reused run id would restart at 0. Close each conversation's run when it ends, or give a batch of conversations a client of its own and close it. quickstart
(1.2) instrument()'s process client remembers which run each call, approval, exchange and stream went to, up to 65,536 of each, the oldest forgotten first: a call that waits for a person while that many newer ones are decided completes in the current run, which may refuse it. Routing needs the run a call was decided in; the maps are bounded so a long-running process doesn't grow. Use a run per conversation (conversation.current()); answer approvals before tens of thousands of newer calls. quickstart
(1.2) TypeScript: a run's event calls go out one at a time on one connection, and a call's timeout includes its wait for its turn, so a busy process run (instrument()'s, shared by every conversation) can time out on a signer that is only slow; the run's fail mode then applies. Events reach the signer in client_seq order. Give each conversation its own run (withRun), or raise timeoutMs. TypeScript SDK
(1.2) The Vercel AI SDK adapter keeps the arguments string of up to 4,096 tool calls whose tool has no execute when toolApproval isn't used. It keeps the model's own arguments for the call's decide until the call runs. Pass toolApproval, or wrap only tools with an execute. TypeScript SDK
(1.2) The Claude Agent SDK hooks (Python and TypeScript) leave every tool of the MCP server keyed tracekit-executor in mcp_servers to the executor, whatever that server is: an app that loads project settings (setting_sources with project) lets a project .mcp.json add such a server. The executor decides and records its own tools; the hooks know them by that key. Don't load project MCP settings in a gated agent, or deny writes to .mcp.json. executor
It only gates agents, tools, graphs, options and sessions built after it runs. It wires each framework where its objects are built: Agent, ToolNode, ClaudeAgentOptions, ClientSession.call_tool, Browser Use's Tools, Pydantic AI's Agent (CrewAI's hooks are global: they gate every crew). Make import tracekit; tracekit.instrument() the first line of your agent. quickstart
It raises (in TypeScript, rejects with) SignerUnavailable when no signer answers, so the app can't start. A run's fail mode is closed until a signer says otherwise. Without a run, no call could be recorded. Follow the message. With no TRACEKIT_SIGNER set, it starts a dev signer itself. With it set, start that signer or unset it. In system mode, run sudo tracekit doctor. quickstart
A process has one run unless you open a run per conversation, and the run closes when the process exits. A killed process leaves its run open until the signer's idle timeout. A process run the signer closed after it was idle is replaced by a new one on the next call (a logged warning), linked to it as its parent_run only while the old run is still closing. The run belongs to the process, not to any one agent loop or session. tracekit last names a run that is still open and skips it. A server opens a run per conversation and makes it current per request: with conversation.current():. quickstart
(1.2) With instrument(), the current run (a run per conversation) reaches the threads and tasks that copy the context: asyncio tasks and asyncio.to_thread do; a plain ThreadPoolExecutor.submit or threading.Thread doesn't, so its calls go to the process run. A contextvars.ContextVar follows the context it was set in. Submit work with contextvars.copy_context().run, or make the run current inside the thread (with conversation.current():). Don't hold a current() block across a sync generator's yield that a server iterates in a thread pool (each step runs in a fresh context): make the run current inside each step, or use an async generator. quickstart
(1.2) A chat app that opens a new run per request while it resends the conversation's history gets a reconcile.result_without_call for each earlier tool result: the new run never saw those calls. Results are recorded as sent in every request; the signer can only check them against the calls of the same run. Keep one run per conversation, open across its requests (conversation.current()). quickstart
An ask (a call waiting for a person) only completes where the framework can wait. OpenAI Agents interrupts the run, and your code must apply the decision. LangGraph pauses only with a checkpointer and denies the call without one. MCP waits up to 300 s in call_tool, Browser Use up to 150 s before the action. The Claude Agent SDK waits up to 9 minutes in its hook. Waiting for a person is part of each framework's own control flow. For approvals, wire the adapter by hand as its quickstart shows. OpenAI Agents, LangGraph
A framework version outside the tested range gets a warning. If its adapter can't be wired at all, it is skipped. Each adapter hooks the framework's internals at one tested minor version. Install the versions the warning names, or wire the adapter by hand. adapters
Don't wire an adapter by hand in a process that also calls instrument(). The signer would decide the same call twice. Use one or the other. quickstart
OpenAI Agents SDK hosted tools, computer use and handoffs are not gated. They run outside the tool hooks. Their calls are still in the signed model responses. OpenAI Agents
Every MCP ClientSession in the process shares one signer stream, so their calls are recorded in one order. A run allows 64 streams (streams_per_run), and a server agent may open a session per request. For one stream per session, wrap each session with TracekitSession by hand. MCP
A call decided without the model's tool-call id (CrewAI's hooks, an MCP session) is paired with the model's tool use only by arguments: same model turn, same tool name and arguments, and no other call of that turn alike (paired_by_args on run.final). Two identical calls in one turn, another agent's model call in the same run between the model's answer and the hook (CrewAI async tasks, or several agents in instrument()'s one process run), or a tool the adapter names differently from the model (mcp-agent's <server>_<tool> against mcp:<server>/<tool>), get reconcile.hook_missing and reconcile.fabricated. An ask in CrewAI waits at most 5 minutes in the hook. The framework hands the hook no call id; pairing anything less certain could hide a call that ran ungated. One approval wait is capped at the signer's 300 s. Read those runs' reconcile.* records as unpaired, not as ungated, when the model's tool names differ from the decided ones; give each crew or task its own run where agents run at once; answer CrewAI approvals within 5 minutes or the call is blocked and the model told. CrewAI, gateway
(1.2) On CrewAI's text (ReAct) tool path, for models without native tool calling, CrewAI retries a failing tool inside one call of its hooks: the retries run under that call's one decision, and only the last outcome is recorded. CrewAI's hooks run once around its tool use, not per attempt; nothing public is called between attempts. Use a model with native tool calling, where each call is decided; or keep tools whose retries matter idempotent. CrewAI
Pydantic AI's text output functions (TextOutput), and argument rewrites in another capability's before_tool_validate, are not decided. A deferred tool (ApprovalRequired, CallDeferred) resumed under the same call id is decided again as its next attempt, and its first attempt reads as decided_not_completed. The Tracekit capability decides tool calls and output tool calls; a text output function has no tool call, and validation runs before the arguments it compares. Register a function with side effects as a tool or a ToolOutput; rewrite arguments in before_tool_execute or wrap_tool_execute, where the change is recorded as an error. Pydantic AI
(1.3) Pydantic AI and Microsoft Agent Framework: a call the framework rejects before Tracekit sees it (Pydantic AI: arguments that fail validation, a tool the agent doesn't have; Agent Framework: a function it doesn't have) is never decided, so with the LLM gateway it reads as a live reconcile.hook_missing, whose detail says the call ran with no hook gating it, though it never ran. Pydantic AI validates the arguments and finds the tool before wrap_tool_execute; Agent Framework answers an unknown function before function middleware. The gateway then sees a result (the retry prompt, the error) sent back for a tool use no decide covers. Read such a hook_missing with the model's next request: what it sends for that id is the framework's retry prompt or error, not a tool's output. Pydantic AI
Microsoft Agent Framework: the signer decides the arguments Agent Framework validated from the model's JSON (values in the parameters' types, keys the function doesn't take dropped), not the model's JSON text, labelled args_source: coerced, so reconcile doesn't compare them with the model's tool use as the agent reports it; it does compare them with the LLM gateway's record of it (the label is the agent's word), where a converted or dropped value reads as reconcile.args_mismatch "(the decide says a framework coerced them)". A SecretStr argument is decided as **********. A middleware listed after Tracekit that rewrites them runs the call with arguments the signer never decided (recorded as status: error). Function middleware is handed the validated arguments, never the model's raw call, and runs in list order. Read a decision's arguments as what the function ran with; list argument-repair middleware before Tracekit; behind the LLM gateway, read such an args_mismatch against the gateway's record of the call. Microsoft Agent Framework
Microsoft Agent Framework's own tool approvals (approval_mode="always_require") are not in the bundle, and a call its approver rejects leaves no record. It holds the call for its approver before any function middleware runs; Tracekit decides the call once it is approved and replayed. Use the policy's ask for approvals the bundle should hold. Microsoft Agent Framework
Microsoft Agent Framework: not decided are tools the provider runs (hosted MCP, web and file search, code interpreter), calls it hands back to the caller (declaration_only, additional_tools), the calls of a delegate (agent.as_tool()) or workflow agent that doesn't have the middleware itself, and middleware given to workflow.as_agent(...), which it never runs. Only calls Agent Framework runs through the function middleware of the agent, run or chat client running them reach Tracekit. Give every agent Tracekit(run), or put it on the chat client they share; hosted calls are seen only in the model responses the gateway records. Microsoft Agent Framework
Microsoft Agent Framework: tools a CodeAct provider (agent-framework-monty, agent-framework-hyperlight) exposes inside the code it runs are not decided; only the execute_code call and its code are. The provider calls them through FunctionTool.invoke from the generated code, outside function middleware. Give a tool that must be gated to the agent directly, not to a CodeAct provider. Microsoft Agent Framework
Microsoft Agent Framework: a refusal reaches the model as the call's result text, not as Agent Framework's error result, so its error limit doesn't count it. Middleware that doesn't call the next one can only set the result; an exception would show the model only Error: Function failed.. Nothing: the text starts Tool call blocked by Tracekit, Tool call held by Tracekit or Tool call refused. Microsoft Agent Framework
Microsoft Agent Framework: Tracekit is tested with Agent Framework 1.22 only and tracekit.instrument() doesn't add it; another version gets no warning. An ask blocks the call, holding a thread of asyncio's default executor, so many asks at once can starve it. The middleware is built by hand, with no version check, and calls the signer through the synchronous client. Install agent-framework-core>=1.22,<1.23 and build Tracekit(run) yourself; answer approvals promptly. Microsoft Agent Framework
Google ADK: a call ADK pauses for its own tool confirmation or for credentials is decided again when ADK runs it again (its next attempt; the first reads decided_not_completed), so a tool under both a Tracekit ask and ADK's confirmation is approved in Tracekit twice. A paused call another process resumes is decided as attempt 0 again, and refused under an ask. ADK runs a paused call again under the same call id; the plugin counts attempts in memory, and ADK's confirmation is not a signer approval. Use Tracekit's ask, not ADK's confirmation, for an approval in the evidence; resume a paused session in the process that paused it. Google ADK
Google ADK: handing a task to a task-mode sub-agent (LlmAgent(mode="task")) is not decided, only the sub-agent's own tool calls are. The agent's own before_tool_callback runs after the decision: arguments it rewrites run undecided (recorded as an error, "the call's arguments changed after the decision") and an answer it returns instead of the tool is recorded as ok, as if the tool ran. ComputerUseTool rescales its coordinates in place, so its coordinate actions read as changed after the decision. A tool confirmation's payload reaches the tool undecided. ADK runs a task-mode sub-agent outside the tool callbacks (an answer from before_agent_callback would leave the hand-off unanswered and the session stuck with that sub-agent), runs the agent's callbacks after every plugin's, and hands the payload to the tool directly. Gate what the sub-agent does through its tools, or hand over with transfer_to_agent or AgentTool, which are decided; keep argument rewrites and canned answers in the tool, not the agent's before_tool_callback; read computer-use "arguments changed" records as its rescaling; carry nothing policy must see in a confirmation's payload. Google ADK
Google ADK: code an agent's code_executor runs, builtin tools the model provider runs, and the tools of an agent run as AgentTool(include_plugins=False) are not decided. A long-running tool, or a streaming tool in a live run, is recorded with the pending status it returned, not its final result. They never reach the Runner's plugin callbacks, or finish after them. Give side effects to function or MCP tools and leave include_plugins on; read a long-running tool's final result in the ADK session next to the bundle. Google ADK
Google ADK: a plugin listed before Tracekit can change a call's arguments before the decision, answer its error (recorded as ok) or replace its result (decided_not_completed). A result a later after_tool_callback replaces is recorded as the tool returned it, and a tool that changes its own arguments in place reads as changed after the decision. ADK runs plugin callbacks in the order listed and stops at the first that answers; Tracekit sees only its own place in that chain. List Tracekit first in plugins (a warning says so when it isn't); change results and arguments in the tool itself. Google ADK
(1.3) Haystack: with raise_on_tool_invocation_failure=True, a tool that raises ends the agent run before after_tool, so the calls of its step read as decided_not_completed; so does a tool output that fails to merge into the agent's state (outputs_to_state), whatever that setting. The Tracekit hook records outcomes at after_tool; Haystack raises past it. Keep the default raise_on_tool_invocation_failure=False: the model reads the error and the call is recorded as one; give outputs_to_state handlers that don't raise. Haystack
(1.3) Haystack: an after_tool hook listed before Tracekit that drops a tool result (or a whole step) leaves that call decided_not_completed. The hook records the results it finds after the step's calls; a dropped one is gone. Under after_tool, list Tracekit ahead of a hook that drops results, or keep that hook from dropping the step's own. Haystack
(1.3) Haystack: tools the model provider runs itself (OpenAIResponsesChatGenerator's built-in web_search, a remote mcp server) are not gated. They run on the provider's side and never reach the Agent's hooks. Read them in the model responses autotrace records under instrument(), or give the agent Haystack tools instead. Haystack
(1.3) Haystack: a value a tool writes to the agent's state (outputs_to_state) and a later tool reads from it (inputs_from_state, a State parameter) is not decided, so from: untrusted rules don't see an untrusted tool's values passed that way. Only the model's arguments are decided; Haystack adds state values after the hook. Don't pass an untrusted tool's output to another tool through state; let the model pass it as an argument, where it is decided. Haystack
(1.3) Haystack: a before_tool hook listed after Tracekit can add a call, which runs undecided; a call it changes runs, recorded as an error. Haystack runs the calls of the last message once every before_tool hook has returned. List Tracekit last under before_tool and after_tool. Haystack
(1.3) Haystack: an ask holds its whole step, so the step's allowed calls wait for the answer too (up to 5 minutes). The hook decides a step's calls at before_tool, before Haystack runs any of them. Answer approvals promptly; tools that often need one can be given to an agent of their own. Haystack
(1.3) Haystack: a refused call's error result is not streamed (streaming_callback) or counted (tool_call_counts); an agent holding the hook can't be serialized (SerializationError); with instrument(), an mcp-haystack tool is decided twice. Haystack streams and counts only the calls it runs; the hook holds a live run; instrument() wires MCP's ClientSession on its own. Read refusals in the messages; build agents in code; don't call instrument() in a process whose Haystack agents use MCP tools. Haystack
(1.3) Haystack: a call without an id is decided under haystack-<hex>, which the hook writes into the history, so its result is checked under that id; it pairs with the LLM gateway's tool use only by its arguments (paired_by_args), never when two calls of a turn are alike. The model's response gave the call no id, so the gateway records it under one of its own. Use a chat generator that returns the model's tool-call ids (OpenAI, Anthropic). Haystack
(1.3) Strands Agents: an ask waits at most 5 minutes in the BeforeToolCallEvent hook, not in a Strands interrupt; a call an interrupt stops after Tracekit decided it (another hook's event.interrupt, the tool's own ToolContext.interrupt or its sub-agent's, which also re-runs the tool from the start) is decided again when the agent resumes, and an ask asks a person again. An interrupt's response comes from the app, not from an approver the signer knows; Tracekit decides before other hooks and the tool run. One approval wait is capped at the signer's 300 s. Answer the approvals of Strands calls within 5 minutes, or the call is refused and the model told; gate a call with a Tracekit ask or with an interrupt, not both. Strands
(1.3) Strands Agents: a hook that runs after Tracekit's last check (at math.inf, added after it) isn't stopped: a call it changes runs, recorded as status: error; a refusal it clears runs, signed as executed_against_policy; one that also changes the call's toolUseId leaves it uncompleted (decided_not_completed if it was allowed, no record if it was refused). Strands' internal tool middleware can replace a call unseen. Tracekit's last BeforeToolCallEvent callback runs at math.inf; a hook added after it at the same order runs later, and nothing between the hooks and the tool can be checked. Don't add hooks at math.inf; read those records as a hook's doing, not the model's. Strands
(1.3) Strands Agents: arguments that aren't JSON are decided as {}; with background_tasks, a background tool another hook asks to retry runs again neither decided nor recorded; a result of several content blocks doesn't match the gateway's verified tool results. Strands parses the model's arguments before any hook ({} is what the tool gets); a background retry fires no BeforeToolCallEvent; the gateway matches one form of a result, and Strands joins blocks differently per provider. Read {} arguments as "the model's text didn't parse"; don't set retry for background tools; return one text or json block from a tool whose results you verify. Strands
(1.3) LlamaIndex: only the object given to Tracekit(run).gate() is gated. An AgentWorkflow runs every agent's tools in its own call_tool step, so agents gated alone gate nothing there; tools called outside an agent workflow's step (llm.predict_and_call, router or sub-question query engines) run ungated, and the functions CodeActAgent's code calls inside code_execute_fn aren't decided one by one. While a gated call runs, instrument()'s MCP session decides nothing made inside it, so an ungated agent that a tool runs has its MCP calls undecided. The gate overrides the call_tool step of what it gates; CodeActAgent runs the model's code as one execute call; a call the gate decided must not be decided again by the session it runs through. Gate the object you .run() (the workflow, else the agent) and every agent a tool runs; run CodeActAgent's code in a sandbox. LlamaIndex
(1.3) LlamaIndex: ReActAgent and CodeActAgent calls are decided under an id LlamaIndex makes, so with model calls recorded (the LLM gateway, autotrace) each reads as reconcile.fabricated. A call the model integration names by its tool (Ollama's, Google's) or gives no id is decided under a UUID of the gate's own: it pairs with the model's tool use only by its arguments (paired_by_args), never when two calls in one turn are alike, and it gets a new UUID, not a next attempt, when it runs again. A tool that waits for an event (ctx.wait_for_event) runs again from its start and is decided again as its next attempt: an ask is asked again, and the first attempt reads as decided_not_completed; a process that resumes a saved Context counts attempts from 0 again. Their model writes an action as text, with no tool-use id; an id that names the tool would make two calls of it one; LlamaIndex runs a step that waited for an event again from the start, and the attempt count lives in the Tracekit object. Use a FunctionAgent with a function-calling model where reconciliation matters; in a tool that waits for an event, do its side effects after the wait. LlamaIndex
(1.3) LlamaIndex: a gated object is bound to its Tracekit's run for life (gating it again raises), and an untrusted tool's structure that can't be observed only warns, leaving the values the text's patterns don't find out of provenance. An ask holds its call_tool step for up to 5 minutes; a workflow timeout under that cancels the wait and withdraws the approval, and each wait holds a thread of asyncio's default executor. A call_tool your own subclass declares with num_workers or a retry_policy runs with LlamaIndex's defaults once gated, and an argument change in an override of LlamaIndex's private _call_tool is not seen. The gated class holds its Tracekit, and the observe is one more request after the tool ran. The decision is made inside the step, before LlamaIndex's call_tool runs; the gated class declares that step once, with the defaults, and compares the arguments call_tool reports with the decided ones. Build and gate the workflow inside each run (a server: per conversation); leave the workflow's timeout unset or above 300 s, answer approvals within 5 minutes, and keep fewer asks waiting at once than the executor has threads; don't rely on step options on a gated call_tool, and change arguments in a call_tool override, where the change is recorded as an error. LlamaIndex
A framework that catches exceptions around a tool or model call can turn a Tracekit refusal into an empty answer or a call that runs anyway (mcp-agent's generate_str returns "" on a refused model call). The refusal is an exception (tracekit.Refused) in the framework's control flow. Let tracekit.Refused through your own error handling; see the per-framework notes. custom
Every Browser Use Tools built after instrument() shares one signer stream, so the actions of all its agents are decided one at a time. A run allows 64 streams, and each Agent builds its own Tools. For one stream per agent, wrap each Tools with trace_tools by hand. Browser Use
tracekit.instrument(tracer) (the v1 model-call tracer) is now tracekit.autotrace.instrument(tracer). tracekit.instrument() takes the run's name and raises TypeError with that fix for anything else. tracekit.instrument() is the one-line v2 entry point. Call tracekit.autotrace.instrument(tracer), and tracekit.uninstrument() to undo it. autotrace
Model calls are recorded only when made through the OpenAI, Anthropic or Google Gen AI Python SDKs. Those are the clients autotrace wraps. Record other calls with the client (run.call("model_event", ...)). custom
In TypeScript, instrument() returns the adapters for you to pass to the framework. It doesn't wire them. ES modules can't be patched from outside. const tk = await instrument(), then tk.ai (Vercel AI SDK) or tk.agents (OpenAI Agents JS). TypeScript SDK
In TypeScript, the run closes on beforeExit, and on SIGINT or SIGTERM when nothing else handles the signal (then the signal's default action runs), waiting at most 2 s for the signer. A signal your app or a library handles is left to it: the run closes at beforeExit if the process gets there, else it stays open until the idle timeout, as after process.exit() or an uncaught exception. @openai/agents installs SIGINT/SIGTERM handlers that exit the process. Node runs no async work after process.exit(), and a handler's own shutdown must not be cut short. In your own signal handler, await tk.run.close() before you exit. TypeScript SDK
(1.2) In TypeScript, a run with approvals still waiting for a person, or approved and not yet consumed, is left open when the process exits, for another process to approve and resume; the signer closes it after its idle timeout. Closing it would expire the approvals before anyone answers them. Save the run's id and token with the framework's state and re-attach in the resuming process: new RunHandle(new Client(), { run_id, run_token }). TypeScript SDK
(1.2) In TypeScript, the adapters use the run made current with withRun(run, fn) (a run per conversation) for each call decided inside it, and the run they were built with otherwise; instrument()'s own run is replaced by a new one, linked as its parent_run while the old one is closing, when the signer closed it after it was idle. A call's complete and approvals go to the run it was decided in. A server makes each conversation's run current per request: withRun(conversation, () => runner.run(...)). TypeScript SDK
(1.2) OpenAI Agents JS: a sub-agent used as a tool (agent.asTool()) isn't gated, and without the LLM gateway its model responses aren't recorded, so its own gated calls reconcile as fabricated. asTool() builds the tool itself; the nested run's responses aren't in the outer result's rawResponses. Gate the sub-agent's own tools with tk.agents.tool, record the nested run's responses (customOutputExtractor gets its result), or run the LLM gateway. TypeScript SDK
(1.2) OpenAI Agents JS: with toolExecution.preApprovalInputGuardrails on, every ask is refused at once instead of interrupting the run (the approval stays requested until it expires). Tracekit's guardrail consumes the approval; run before the interruption, it finds none granted. Leave preApprovalInputGuardrails off. TypeScript SDK
(1.2) OpenAI Agents JS: a local MCP server's call that fails reaches no output guardrail, so tk.agents.mcp(server) leaves it decided and not completed (a decided_not_completed gap). The SDK runs output guardrails only for a call that returned. Read such a gap next to the MCP server's own log. TypeScript SDK
(1.2) OpenAI Agents JS with mcpConfig.includeServerInToolNames: tk.agents.mcp(server) turns the model's mcp_<server>__<tool> back into mcp:<server>/<tool> only when server is the MCP server's own name; a name the SDK shortened or hashed (long or colliding) is decided as given. The SDK builds the prefixed name itself, from the server's name. Pass the server's name; keep tool names short and distinct. TypeScript SDK
(1.2) Vercel AI: with toolApprovalWith(app), a call your app answers user-approval and its person then denies in your app is left decided and not completed (a decided_not_completed gap); a call your app answers denied at once is recorded as refused. The SDK tells the adapter nothing when your own approval UI says no. Read such a gap as your app's refusal, or answer denied from the app function. TypeScript SDK
(1.2) With the LLM gateway on the same run, model exchanges are recorded twice: the gateway's request and response, and the adapter's (recordResponses, the Vercel middleware). Both record what they saw; the signer doesn't merge them. With the gateway, the adapter's model events are optional; count exchanges by the gateway's (source: gateway). gateway

tracekit last

Limit Why it is so What to do Details
It pins the dev signer's key from the signer's own data dir, so the verdict is dev assurance. The dev signer runs as your user. Anything that runs as you could also have rewritten that key and the log. For evidence against the agent, use system mode and witnesses, and pin keys you got another way. what dev assurance means
It shows finished runs only. A newer run that is still open is named and skipped. A run that just closed is waited for, up to 15 s. The signer finalises a run a few seconds after the run closes. A bundle exported before that verifies only to its head. End the agent, then run tracekit last again; or tracekit last --open (1.2.2) for the newest run as it stands, such as a coding agent's session, verified to its head. quickstart
It writes <run id>.tkb and <run id>.trust.json in the current directory. They sit where you can find them. Use -o PATH to write somewhere else. quickstart
It reads only the same-user dev signer. A configured signer's store and keys belong to another user or host. tracekit signer trust --config signer.yaml -o trust.json, then tracekit export --v2 --config signer.yaml --run ID. deploy

Platforms

Limit Why it is so What to do Details
Windows: dev mode only, with no caller attestation; v1 approvals are refused. Python exposes no peer-credential or process-tree call there. Point the witness at storage the agent's user can't rewrite. platforms
macOS system mode is experimental and not validated on hardware. launchd and user creation are unit-tested with mocks only. Use --experimental-macos knowingly; Linux for production. platforms
Harness binding works on Linux system mode only. It reads the process tree from /proc. Use a separate user or a gateway elsewhere. platforms
The Claude Code plugin's hook script is POSIX shell. On native Windows Claude Code runs it in Git Bash (untested); without Git Bash, in PowerShell, which can't run it, so every tool call is blocked. Managed settings aren't wired up on Windows. The wrapper is POSIX shell. Without Git Bash, don't install the plugin: use tracekit init --dev --v2. plugin, platforms
CI blocks merges on Linux only; macOS and Windows jobs are advisory. Root-only and /proc tests need Linux. Treat a green macOS or Windows run as advisory. platforms
The committed performance numbers are from one macOS machine with --quick, file and Postgres stores; the latency gate is enforced on Linux only, and the Postgres run missed it (p99 5.17 ms). Linux is where the gates run. Run make eval or eval/e9_signer_perf.py on your own hardware. FAQ
Not measured: a separate-user signer's throughput, the HTTPS transport, cross-host throughput, adversarial-model bypasses, Windows and macOS system mode, an external review. Not built or not run yet. Measure what you depend on. evaluation
The LangChain, MCP and OpenAI Agents adapters need Python 3.10 or newer. Their frameworks require it. Use Python 3.10+ for those adapters. platforms, MCP
The threat models haven't been reviewed by anyone outside the project yet. Issue #1 tracks the review. Read the review packet. laptop threat model

Home

Get started

How it works

Integrations

Policy and approvals

Deploy and operate

Verify the evidence

Security and privacy

Compliance

Evaluation

Demos

Help

Clone this wiki locally