Skip to Content
Changelog

Changelog

All notable changes are documented here. The format follows Keep a Changelog; the project follows Semantic Versioning once 1.0.0 is reached.

[Unreleased]

[0.8.1] - 2026-11-05

Which MCP server answered: every MCP call carries the name and version the server reported about itself. Plus an AIUC-1 evidence map and a Langfuse example.

Added

  • Inspector: the step details show the MCP server’s self-reported name and version.

  • sealedrun.mcp.server_info: the name and version an MCP server reports about itself, recorded by the MCP proxy and sealedrun-mcp-wrap on every call (SPEC 10.2). Self-reported by the server, absent when it sends none.

  • examples/langfuse: one model call traced in Langfuse and sealed in a run, with the Langfuse trace id as the run label.

  • docs/aiuc-1.md: which part of a bundle is evidence for which AIUC-1 control, with recipes for alerts, SIEM and Vanta.

Changed

  • README, SPEC and TRUST.md say who holds the signing keys: the recorder, not the model or the agent code. “Agent” in the format names the signing identity.

[0.8.0] - 2026-11-01

Who allowed it, and under which instructions: the verifier binds every step that required a human’s approval to the answer that preceded it, and every model call carries the digest of the system prompt in force.

Added

  • SPEC 6.6 approval check and 10.10 sealedrun.approval: a human_approval record now carries the human’s decision (granted / denied) and, as its payload, the request the human was shown. Both verifiers bind it to the step that then ran under policy.decision = "require_approval" by payload.request_hash and report approval.missing (no approval for that request before the step) and approval.denied_but_executed as findings; one approval covers one execution. strict_approval / strictApproval and the CLI flag --strict-approval turn the first finding into a failure. SDK approve / approve records the answer. Vectors spec/vectors/approval/. Inspector shows the human decision on the record and counts approval findings in the verdict. Records from earlier versions verify as before: a human_approval without the extension matches nothing.
  • SPEC 10.1 sealedrun.llm.system_prompt_hash: the digest of a model call’s system instructions under the run’s hash algorithm. The recorder proxy derives it from the request for the OpenAI chat and Responses, Anthropic, Ollama chat and generate, and Gemini shapes; a posted step passes system_prompt (SDK option system_prompt / systemPrompt) and the recorder stores the digest only. Both verifiers report system_prompt_changes / systemPromptChanges, the seq of every model call whose instructions differ from the previous one’s; the CLI prints system prompt changed, the Inspector tags the step and shows the digest. Vector spec/vectors/llm/system-prompt-change.jsonl. Mapped to OTel gen_ai.system_instructions in SPEC 11.
  • scripts/demo_tamper.py edits one field of one record inside a bundle so anyone can watch the verifier refuse it; scripts/render_demo.py records that as docs/demo-tamper.gif and .mp4 from the real commands, so the clip is re-made at each release. The README opens with it.

[0.7.0] - 2026-10-31

Evidence, not a log: the verifier reports every record outside the Principal’s scope, checks the changed system’s own receipt on each step, and follows parent links between delegated runs.

Added

  • SPEC 6.5 scope check: four keys of Delegation.scope now have a defined meaning (allowed_targets with * globs on name, allowed_locations, denied_labels, max_records); other keys stay free-form. Both verifiers report every record outside the scope as a finding (scope.target, scope.location, scope.label, scope.max_records) with its seq and record id, so an auditor sees where the agent went beyond what the Principal allowed without reading the run. strict_scope / strictScope and the CLI flag --strict-scope turn the first finding into a failure. The CLI prints the findings per run. Vectors spec/vectors/scope/. The recorder writes SEALEDRUN_SCOPE into its own delegation and reissues it when the scope changes.
  • SPEC 10.9 sealedrun.receipt: a record can carry the changed system’s own proof of the step (digest of the HTTP reply, commit sha, file digest, transaction id). The recorder proxy writes a proxy receipt on every relayed call (LLM, MCP, A2A) from the reply it saw; SDK receipt_for / receiptFor and the receipt option of record attach a self_reported one, and a posted step claiming proxy is refused. Both verifiers fail with check receipt when a proxy receipt sits on a record the recorder did not relay, is not an http_response, or its digest differs from payload.response_hash, and count accepted receipts by observer; the CLI prints the counts. Vectors records/receipt-run.jsonl and three negative chains.
  • SPEC 6.4 parent link: a run started on behalf of another run names the record that asked for it (parent_run_id, parent_record_id, parent_delegation_hash in sealedrun.delegation). The recorder’s A2A proxy announces its record to the callee in the X-SealedRun-Parent header before the call, and accepts the header on proxied calls and posted steps that open a run; the SDKs send it (parent= / parent:). Both verifiers resolve the link among the runs they hold (check_parent_links / checkParentLinks, run by verify_bundle): the asking record must exist and be a tool_call, the child must not start before the parent run, the delegation hash must match and the child’s scope must be within the parent’s (parent.record, parent.kind, parent.time, parent.delegation, parent.scope); a parent in another bundle is reported as unresolved, not failed. Vectors bundle/parent-*.zip.
  • TRUST.md states what a self-reported step and a receipt do and do not prove (the agent marks its own homework; a proxy receipt is the recorder’s observation, not the agent’s), and README has the three questions the verifier now answers.
  • OTLP receiver: OpenRouter Broadcast traces are recorded in full. Payloads fall back to gen_ai.prompt / gen_ai.completion and span.input / span.output, the string gen_ai.response.finish_reason is read when the array is absent, decisions spans become llm_call records with sealedrun.decision built from the completion, and openrouter and typesafe count as cloud providers. README has the Broadcast setup.

[0.6.0] - 2026-10-18

Decision models: the recorder proxies and records OpenAI Decisions and TypeSafe systemone calls (OpenRouter Jev, Microsoft Foundry Decision-1), extracts the verdict into the record, the SDKs report the gate that acted on it, and the Inspector shows both.

Added

  • Recorder proxy: POST /v1/decisions (OpenAI Decisions API, model gpt-6-luna) is forwarded and recorded as an llm_call with operation decisions; token usage and model are read when the reply carries them, a refusal answer sets finish_reason: refusal. The policy rule applies as to any cloud call. Streaming is refused with 400.
  • Recorder proxy: dialect systemone for decision models in the TypeSafe wire format (OpenRouter typesafe/jev-*, Microsoft Foundry Decision-1). The recorder accepts POST /api/alpha/decisions, /api/v1/systemone and /providers/microsoft/v1/systemone, forwards to <upstream url>/systemone and records an llm_call with operation decisions, the model snapshot and token counts. api-key and bearer credentials pass through as for other dialects. SPEC 10.3 and the extension schema list the new dialect; upstreams.example.yaml has entries for OpenRouter and Foundry.
  • SPEC 10.7 sealedrun.decision: the recorder extracts a decision model’s answers from the reply of a decisions call into the record, normalised to one list (predicate, choice, score; value, confidence, probabilities, refused), so the verdict survives an export without payload bodies. Both verifiers check the shape; vector records/decision-run.jsonl.
  • SPEC 10.8 sealedrun.gate and SDK record_gate / recordGate (py, ts): a self-reported policy_decision step that names the decision call by parent_record_id and states the thresholds and the outcome (approve, block, review). The SDKs’ record takes parent_record_id / parentRecordId.
  • Recorder proxy: every buffered reply carries the record id in X-SealedRun-Record.
  • Inspector: a decision call shows a “Decision” card with a bar per answer (yes probability, choice or score labels, the chosen one marked, the gate’s threshold drawn on it), the gate’s verdict and a jump to the gate step; a gate step shows its verdict, thresholds and a jump back to the decision. Row tags decision and approve / block / review; a decisions filter chip keeps decision calls and their gates; the text filter matches decision and the verdict.

Fixed

  • sealedrun-py tests: the shared delegation fixture expired on 2026-10-16 and eight tests that write records with the real clock started failing; its validity is now ten years.

[0.5.0] - 2026-10-10

Anchoring is on by default with Sigstore’s time-stamp authority and visible in /api/identity and the Inspector; big runs export without payload bodies and stream to disk; the Inspector run and step lists page, filter and link.

Added

  • SEALEDRUN_ANCHORS selects the anchors (tsa, rekor) in the order tried; anchoring is now ON by default with Sigstore’s time-stamp authority (anchor_tsa_url defaults to it, anchor_rekor_url to the public Rekor log), [] turns it off; DigiCert’s authority is the default fallback. GET /api/identity reports anchors (configured, last success, last failure per anchor) and the Inspector’s recorder tab shows one line: off, ok, or failing with the reason. Before this an operator could believe runs were anchored while the setting was empty and anchors stayed 0.

  • Inspector: the recorder tab is two levels. The run list is a table with a text filter (run id or label), state chips (open, closed, imported) and 25 runs per page; a run’s steps come 50 per page with kind chips, a text filter over model, tool and labels, and a jump to a step number. Tab, filters, page, run and step page live in the URL, so a run can be linked and the back button works. GET /api/runs takes q, since, until, complete, source; GET /api/runs/{id}/records takes kind, outcome, q; both answer X-Total-Count.

  • Export without payload bodies: POST /api/runs/{id}/export?payloads=omit and the Inspector button “Export without payloads” write the records, signatures and receipts only, and the signed manifest says payloads: "omitted" (SPEC 13.1). A 362-call Claude Code session exports in 1.3 MB instead of 662 MB. Both verifiers skip the body checks for such a bundle, report payloads_omitted, and refuse a bundle that says omitted yet carries a body. Vectors bundle/payloads-omitted.zip and payloads-omitted-with-bodies.zip.

  • GET /api/runs/{id} reports payload_bytes, the size a full export would carry; the Inspector shows it on the export button and, over 256 MB, points at the export without payloads. A bundle file over 1 GB is not opened in the browser (the page names the command line instead).

Changed

  • Export builds the archive on disk and streams it: each payload body is read from the database while it is written and the manifest is the last entry, so a 1.2 GB run peaks at about 130 MB in the recorder instead of holding the whole archive and every body in memory. write_bundle accepts a lazy mapping of bodies.
  • Both verifiers accept archives up to 4 GiB uncompressed (was 512 MiB): a Claude Code session of a few hundred calls exports to about 1 GiB because every request carries the whole context, and the verifiers refused it although the chain was intact. The refusal now names the claimed and the allowed size, and python -m sealedrun --max-total-bytes N sets another limit.

[0.4.1] - 2026-10-06

Patch release: a run that opens on a wrapped MCP stdio server can name its agent.

Fixed

  • sealedrun-mcp-wrap --agent <name>/<version> (or SEALEDRUN_AGENT) sends X-SealedRun-Agent with every step it posts. An MCP client such as Codex starts its stdio servers before the first model call, so the run opened on the wrapper’s tools/list, which carried no agent, and the name sent later on the model calls was dropped: the run said “Agent did not name itself”. A malformed value stops the wrapper at start instead of losing records to a 400; the variable is not passed on to the wrapped server.

[0.4.0] - 2026-10-05

Every run now says what produced it (sealedrun.context, SPEC 10.6), and the recorder moves to SQLAlchemy 2.1.

Added

  • sealedrun.context on every run_start (SPEC 10.6): what produced the run. The recorder names itself (recorder_software), the policy set in force (policy_set_hash, a SHA-256 over the canonical rules; the same digest sits in every policy block as policy_set_hash, SPEC 5.7) and, when the run opens on an MCP tools/list, the tool inventory the agent saw (tool_inventory_hash over the tools sorted by name, tool_inventory_server). The agent names itself with X-SealedRun-Agent: <name>/<version> on the first call of a run (any proxy dialect, /api/steps; never forwarded upstream; 400 when malformed), else through the MCP _meta clientInfo or the gen_ai.agent.* span attributes; agent_source says which. Until now a bundle proved a record was not changed, but not which agent version, policy set or tool inventory produced it, so two runs could not be compared. Both verifiers check the shape and fail a run with check context when the extension sits on a record other than run_start or a policy block names another policy set than the run’s. Python Recorder(agent=...) and TypeScript RecorderOptions.agent set the header. Inspector shows a “What produced this run” card and says whether the run is comparable with the one shown before it. New vectors records/context-run.jsonl, chains/policy-set-hash-mismatch.jsonl, chains/context-on-llm-call.jsonl; two-runs.zip and duplicate-record-id-across-runs.zip regenerated with the context on their second run.

Changed

  • Recorder: SQLAlchemy 2.0.54 -> 2.1.3 (uv.lock; the floor in pyproject.toml stays >=2.0). 2.1 flushes pending rows before every statement, types Row per column and drops greenlet from the default install. The recorder only reads through Session.get while rows are pending, which flushed already in 2.0, uses the sync engine only and has no filter_by or text() on a session, so no record is written at a different moment than before; mypy and the full suite pass unchanged on SQLite and on Postgres 17. Closes Dependabot #39.

[0.3.1] - 2026-09-30

Patch release: a recorder started on a data volume written by 0.2.x works again, and a record_id may not repeat across the runs of a bundle.

Changed

  • A record_id may not repeat across the runs of a bundle (SPEC 5.1, 13.2 step 3). verify_bundle / verifyBundle fail with check record_id at the later record, runs taken in manifest order; until now only a repeat inside one run was refused, while anchors/<record_id>.json names an anchor record by that id alone, so two runs sharing an id left it open which record a receipt file belonged to. New vectors spec/vectors/bundle/duplicate-record-id-across-runs.zip and, as the accepted counterpart, two-runs.zip. The recorder refuses such an upload (422). Both verifiers.

Fixed

  • A data volume written by 0.2.x opens on the current release: 0.3.0 answered every /api/runs call with 500 (no such column: runs.labels_self_reported) because the two columns it added to the runs table were never added to an existing database. At start the recorder now adds missing columns (ensure_schema), fills required ones with the model default, creates their indexes and logs the columns added; nothing is dropped or retyped. SQLite and PostgreSQL.

[0.3.0] - 2026-09-29

Stage 1 complete: the recorder sees every channel an agent uses (LLM proxy, MCP over HTTP and stdio, A2A, self-reported steps, OpenTelemetry spans), anchors run heads in external witnesses that both verifiers check offline, carries data labels and the first policy rule, and went through a threat review and a security scan with all findings fixed. A LangGraph example agent and a five-minute path are in examples/langgraph-mcp.

Added

  • examples/langgraph-mcp: a LangGraph agent (create_agent) with the filesystem MCP server under sealedrun-mcp-wrap, models on Ollama or OpenAI through the proxy, its own tests; root README section “Five minutes to a verified run”. docker-compose.yml mounts the root upstreams.yaml (Ollama on the host, OpenAI with OPENAI_API_KEY). End-to-end recorder test test_e2e_agent.py: one agent turn through the LLM and MCP proxies with fake servers, exported and verified by the Python and the TypeScript verifier.

  • Security pass, witness verification (Claude Security scan 2026-09-28, 13 findings, all fixed): the Python rfc3161 verifier adds a receipt’s chain certificates to the trust store only when their signatures lead back to a trust-list root (the library trusts everything it is handed, and a self-issued authority spelt CN=Fake / issuer CN=fake slipped past the old name comparison); the TypeScript verifier matches the signer certificate byte for byte as pkijs does, requires the certificate pkijs verified to be that signer (a decoy with the same serial value in a longer encoding no longer lends its time-stamping key usage to another certificate), builds paths only through authorities signed under a root (two authorities that issued each other made path building loop forever) and refuses tokens with more than 16 certificates. New vectors spec/vectors/anchors/hostile.json (generator packages/sealedrun-py/tests/make_hostile_anchors.py) cover all three. Inspector: witness verdicts are keyed per run and record (two runs may repeat a record id), a rekor anchor shows the log’s integrated_time and never a gen_time written into the record, and for a verified receipt the witness name, root subject and public log link come from the trust entry that verified it, never from the record’s witness or log_url (also removes a quadratic regex on that field).

  • Security pass, recorder and CLI: requests carrying an X-Forwarded-Host outside SEALEDRUN_ALLOWED_HOSTS are refused (400), so the Next dev server’s /api rewrite, which replaces the Host header, no longer lets a DNS-rebound page reach a token-less recorder; A2A sub-paths may hold only plain segments (letters, digits, .-_~:), no percent-encoding or ;, so %252e%252e and ..; never reach the agent; upstream, MCP server and A2A agent URLs with credentials, a query string or a fragment are refused at start (they would be sealed into every record’s target.endpoint); python -m sealedrun replaces control characters in what it prints (an archive path could carry a terminal escape that repaints FAIL as OK), and the manifest files pattern allows printable ASCII only.

  • Data labels from the caller: X-SealedRun-Labels: nda, pii on LLM, MCP HTTP and A2A proxy calls and on POST /api/steps (when the body names none) becomes data_labels with sealedrun.labels[label].source = "header" (new source value in SPEC 5.6 and the extension schema); the header is validated (SPEC 5.6 pattern, at most 64, else 400 without forwarding) and never reaches an upstream.

  • First policy rule: SEALEDRUN_POLICY_BLOCK_TO_CLOUD=["nda"] refuses a call carrying one of the listed labels to an upstream, MCP server or A2A agent with location: cloud before any upstream contact (403 in the dialect’s error shape; JSON-RPC error 40003 for MCP and A2A, PERMISSION_DENIED on the A2A REST binding) and seals a record with outcome: blocked, policy (recorder/no-<label>-to-cloud, block, reason) and the request digest; labelled cloud calls the rule allows carry policy default/allow. Covers tools/call, resources/read, prompts/get, SendMessage, SendStreamingMessage; not the stdio wrapper or self-reported steps.

  • Security pass, low findings: the access log drops query strings (a Gemini ?key= token never lands in a log line); proxy errors returned to clients are generic (upstream unreachable, upstream is not configured on the recorder, same for MCP servers and A2A agents) while the upstream name, key variable and failure class go to the recorder log; Sec-Fetch-Site: same-site is no longer accepted for state-changing API calls or MCP proxy calls, only same-origin, so a sibling subdomain cannot post to a token-less recorder.

  • Security pass, Inspector and UI headers: the public log link of a rekor anchor is offered only when the receipt verified offline and its log_url is https:; run and record ids are URL-encoded in API paths; records fetched from a recorder are validated (assertRecord) before they are shown; one payload body or extension block renders at most 256 KB with a note (download for the rest); a rendering failure stays inside the steps section (error boundary). The recorder sends Content-Security-Policy (self only, no framing), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy on every response, UI and API.

  • Security pass, verifier robustness: read_bundle / readBundle fail with a VerificationError (never a crash) on JSON that is malformed, nested too deep or carries NaN / Infinity, on a delegation or run file that does not match its schema, and on a run file whose records belong to another run; check_hash and check_signatures return False instead of raising on values RFC 8785 cannot express; a record_id that repeats within a run fails verification (record_id, new vector chains/duplicate-record-id.jsonl); a run whose hash_alg differs from the manifest fails (hash_alg); a rekor receipt counts as witness-verified only with a present, plausible integrated_time inside the witness validity (SPEC 8.1.2); a huge log time no longer raises. Both verifiers.

  • Security pass, clients and wrapper: the Python Recorder client never follows a redirect (RecorderError with the 3xx status) and the TypeScript client sends redirect: "error", so the bearer token cannot be lured to another host; sealedrun-mcp-wrap drops --token (visible in /proc/<pid>/cmdline) in favour of SEALEDRUN_TOKEN or --token-file, matches integral float ids (1.0) to their requests, and in --strict mode withholds a server batch or a response with an unmatchable id instead of relaying it unrecorded.

  • Security pass, self-report provenance: every record sealed from POST /api/steps carries sealedrun.step (source: self_reported, new extension, SPEC 10.5), so a step an agent reports about itself is never mistaken for a call the recorder saw; steps carrying sealedrun.proxy, sealedrun.otel, sealedrun.anchor, sealedrun.delegation, sealedrun.imported or sealedrun.tombstone are refused (400), as are sealedrun.labels sources other than manual / header. sealedrun-mcp-wrap now writes sealedrun.step (via: sealedrun-mcp-wrap, latency, truncation) instead of sealedrun.proxy. Run summaries gain labels_self_reported: labels on self-reported cloud-target steps, counted apart from labels_sent_to_cloud, which now covers only records the recorder made itself (the runs table gains that column: delete an old sealedrun.db). The Inspector tags self-reported steps.

  • Security pass, storage integrity: an uploaded bundle can no longer overwrite a stored delegation (same id, different document -> 422 delegation), so a stranger who reads the recorder’s public delegation id cannot make its own exports fail verification; a record whose database write fails is taken back from the in-memory chain (RunWriter.revert) so the next record chains to what is really stored, and the caller gets a LiveRunError instead of a silent chain split; target text is bounded before sealing (type 16, name 256, location 16, endpoint 2048, provider 256 characters: 400 on /api/steps, MCP and A2A, cut on OTLP spans); the anchoring loop logs and continues when one run fails, its end is logged, and its database and signing work runs off the event loop; a streamed reply that cannot be recorded after the client already received it is logged at error level with the bytes delivered.

  • Security pass, size and memory caps: every body the recorder reads (LLM, MCP and A2A proxy requests, POST /api/steps, OTLP exports) is read against SEALEDRUN_PROXY_MAX_BODY_BYTES as it arrives, so a chunked upload without Content-Length is cut at the cap (413) instead of being buffered whole; upstream replies, MCP and A2A JSON replies and agent cards are streamed against the same cap and asked for uncompressed (accept-encoding: identity), an oversize one is answered 502 and recorded (truncated for MCP/A2A); witness replies (TSA, /certchain, Rekor) are capped at 1 MB; JSON nested too deep to parse is 400, not 500. Labelled runs now idle-close like the unlabelled one, the grouper keeps at most 1024 open labels (the least recently used run is ended), live-run writers are bounded to 4096 in memory (resumed from the database when needed), tools/list digests to 4096 keys, anchoring forgets idle run locks. OTLP exports with more than 2000 spans are refused (413). SEALEDRUN_MAX_BUNDLE_BYTES defaults to 64 MB (was 256) and an uploaded archive may inflate to at most four times the cap. sealedrun-mcp-wrap keeps at most 1024 open requests (older ones recorded as truncated), relays lines above 16 MB in pieces without recording them, and bounds a recorder post by --timeout in total.

  • Security pass, proxy governance: the MCP proxy refuses JSON-RPC batches, requests whose id is not a string or an integer, and tools/call / resources/read / prompts/get / tools/list sent without an id (400, never forwarded), since none could be recorded or governed. The A2A proxy records and governs every POST, PUT and PATCH to a cloud agent, whatever its method or sub-path (sealedrun.a2a.method names it), refuses cloud requests it cannot read (400) and batches or bad ids for every agent, recognises the A2A 0.3 names message/send, message/stream, tasks/cancel, and refuses sub-paths with . / .. segments or percent-encoded delimiters. SEALEDRUN_POLICY_BLOCK_TO_CLOUD labels are validated against the SPEC 5.6 pattern at start. Proxy-generated JSON-RPC error codes moved from -32000 / -32003 to the application codes 40000 / 40003 (the MCP specification reserves -32000..-32099).

  • Python verifier checks rfc3161 anchor receipts offline (sealedrun.anchors.rfc3161, on rfc3161-client): imprint, nonce, signer certificate by signer identifier, CMS signature, chain to a root of the witness trust list at the token’s time, time-stamping key usage, gen_time inside the entry’s validity; verify_run and verify_bundle take witnesses (default: the shipped sealedrun/trust/witnesses.json with the Sigstore TSA, DigiCert Trusted Root G4 and the Rekor v1 log key) and strict_witness. anchors_witness_verified counts the verified anchors; a receipt from a witness outside the trust list is bound to the chain but not counted. sealedrun.anchors.rfc3161.request / receipt_from build the time-stamp query and turn the answer into the receipt (DER-normalised). Vectors: receipts captured from the Sigstore TSA and DigiCert for the reference run head (spec/vectors/anchors/), derived tampered cases in cases.json; the reference run’s anchor is now a real Sigstore time-stamp.

  • TypeScript verifier checks rfc3161 anchor receipts offline on WebCrypto (verifyRfc3161, on pkijs): verifyRunAsync, verifyBundleAsync and verifyWitnesses fill anchorsWitnessVerified (the synchronous verifyRun / verifyBundle leave it at 0); shippedWitnesses() is the same trust list as the Python package, witnesses and strictWitness options as there. Cross-signed copies of a trust root inside a token (DigiCert) and self-signed certificates offered by a receipt are never used for path building. The Inspector verifies anchor receipts of an opened bundle and shows, per anchor, the witness, its time and whether the proof verified.

  • Recorder anchoring: with SEALEDRUN_ANCHOR_TSA_URL set (for example https://timestamp.sigstore.dev/api/v1/timestamp, optional SEALEDRUN_ANCHOR_TSA_FALLBACK_URL such as http://timestamp.digicert.com), a background task time-stamps the head of every open live run whose head moved, every SEALEDRUN_ANCHOR_INTERVAL_SECONDS (600), and POST /api/runs/{id}/export anchors the head first, before run_end with end=true. Each receipt becomes a signed anchor record (rfc3161, with the authority’s published chain when it has a /certchain endpoint); a failing authority is retried once, then the fallback is tried, then the run goes on unanchored with a log line. Run summaries gain last_anchor_at; the runs table gains that column (delete an old sealedrun.db).

  • Sigstore Rekor v1 anchors: the recorder keeps a P-256 anchoring key (keys/anchor.pem, published as anchor_public_key in GET /api/identity) and, with SEALEDRUN_ANCHOR_REKOR_URL set (for example https://rekor.sigstore.dev), publishes every anchored head as a hashedrekord entry and writes a second anchor record of type rekor with the entry, its inclusion proof and checkpoint. Python verifier: sealedrun.anchors.rekor (entry_for, receipt_from, verify) checks the signed entry timestamp with the log key from the trust list, the RFC 6962 inclusion path, the checkpoint signature (C2SP note, cosignatures ignored), the anchoring signature over the head and the entry body, offline; verify_run counts verified rekor anchors. Vector: an entry captured on the public log for the reference head (spec/vectors/anchors/rekor-sigstore.json) plus derived cases. The head is signed as a prehashed message, so the log entry attests the record itself (SPEC 8.1.2).

  • TypeScript verifier checks rekor receipts offline (verifyRekor on @noble/curves P-256, browser-safe): log key by log_id, entry body, anchoring signature, signed entry timestamp, RFC 6962 inclusion path and the C2SP checkpoint; verifyReceipt dispatches by anchor type and verifyRunAsync / verifyBundleAsync count both types. The Inspector shows a Rekor anchor’s log index and a link to the public entry.

  • SPEC 8 defines the witness receipts: rfc3161 (RFC 3161 time-stamp token over the chain head, with the authority’s certificate chain snapshot and the request nonce) and rekor (Sigstore Rekor v1 hashedrekord entry with its inclusion proof and checkpoint, signed by a P-256 anchoring key), plus the offline verification procedure and the witness trust list (SPEC 8.4). The schema checks both receipt shapes; anchors_witness_verified now means anchors verified against a witness in the trust list.

  • Recorder MCP proxy for Streamable HTTP servers: POST, GET and DELETE /mcp/<name>, servers listed under mcp_servers in the upstreams file (URL, key_env with auth bearer / x-api-key / api-key, static headers, client_auth: passthrough, location). Both MCP eras pass through unchanged: 2026-07-28 (stateless, Mcp-Method / Mcp-Name / Mcp-Param-* headers) and 2025-03-26 to 2025-11-25 (initialize, Mcp-Session-Id, GET stream, DELETE). JSON and SSE replies are relayed chunk by chunk with X-Accel-Buffering: no; cross-site browser calls are refused. tools/call, resources/read, prompts/get and tools/list accepted by the server become tool_call records in the same runs as the LLM calls (X-SealedRun-Run); a JSON-RPC error or isError gives outcome error, resultType: input_required gives pending, a stream that ends before the response is truncated. tools/list is recorded once per run, server and cursor until its result changes. Tested with the MCP Python SDK 2.2 and 1.30 in both eras, Claude Code (MCP 2026-07-28) and Codex (MCP 2025-06-18, model on Ollama).

  • sealedrun.mcp gains protocol_version, result_type and session (a SHA-256 of the session id); sealedrun.proxy.dialect gains mcp.

  • sealedrun-mcp-wrap (in the sealedrun package, standard library only): wraps an MCP stdio server, relays stdin/stdout unchanged, forwards signals and the exit code, and records tools/call, resources/read, prompts/get and tools/list as tool_call records with transport: stdio by the HTTP proxy’s rules, posting each before the reply is delivered. Cancelled or abandoned requests are recorded as truncated. Fail-open by default with a stderr warning; --strict answers the client with a JSON-RPC error when a call cannot be recorded.

  • SDK hooks for self-reported steps: Python sealedrun.Recorder (record, with recorder.step(...), @recorder.tool) and TypeScript Recorder (record, step, wrap) in @sealedrun/core, both posting to POST /api/steps; RecorderError on refusal or an unreachable recorder.

  • Recorder A2A proxy: /a2a/<name> for A2A 1.0 agents listed under a2a_agents in the upstreams file (JSON-RPC and REST bindings, SSE relayed chunk by chunk, A2A-Version / A2A-Extensions passed through), agent card served with its interface URLs rewritten to the proxy. SendMessage, SendStreamingMessage and CancelTask become tool_call records with the new sealedrun.a2a extension (SPEC 10.4) and outcome by task state; sealedrun.proxy.dialect gains a2a. Tested with the A2A Python SDK 1.1.5.

  • OTLP/HTTP trace receiver POST /otlp/v1/traces (binary protobuf or JSON, gzip/deflate, recorder token): OpenTelemetry GenAI spans become records by gen_ai.operation.name (execute_tool -> tool_call, model operations -> llm_call, memory operations -> memory_read / memory_write); one trace = one run (X-SealedRun-Run, else the trace id); span status ERROR -> outcome error; invalid spans reported in partial_success; re-sent spans recorded once. sealedrun.otel gains optional operation, span_name, started_at, duration_ms, scope, agent, conversation. Tested with the OpenTelemetry SDK and a LangGraph agent on Ollama traced by opentelemetry-instrumentation-langchain.

  • Recorder self-report endpoint POST /api/steps (label in X-SealedRun-Run) and POST /api/runs/{id}/steps: a JSON step (kind, target, outcome, extensions, payload text or base64) is validated as a whole record against the schema before it is sealed onto the live run, so a bad step never breaks a run’s verification. The wrapped server’s command line is not recorded (only the executable name) and the wrapped process does not inherit SEALEDRUN_TOKEN. tools/list steps are deduplicated per run, server, transport and cursor like proxied ones.

[0.2.0] - 2026-09-26

Stage 1: the recorder as a live LLM proxy. Every model call made through it becomes a signed record in a live run that can be exported as a bundle at any time. Tested live with the OpenAI, Anthropic and Ollama SDKs, Claude Code (API key and Pro/Max login) and Codex.

Added

  • Recorder LLM proxy, OpenAI wire format: POST /v1/chat/completions, POST /v1/responses, POST /v1/embeddings, GET /v1/models. Upstreams come from upstreams.yaml (SEALEDRUN_UPSTREAMS_FILE) and are routed by wire format and model name pattern, so one model can sit behind several formats; each upstream sets how its key is sent (bearer, x-api-key, x-goog-api-key, api-key) and optional static headers. services/recorder/upstreams.example.yaml covers OpenAI, Azure OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Qwen, GLM, MiniMax, Mistral, xAI, Groq, OpenRouter, Ollama, LM Studio, vLLM and llama.cpp. The client sends SEALEDRUN_API_TOKEN wherever its SDK puts the API key and the upstream key is swapped in server side. Every call becomes a signed llm_call record with request and response bodies, never headers. Calls with the same X-SealedRun-Run label share a run; unlabelled calls share a run until it is idle for SEALEDRUN_PROXY_RUN_IDLE_SECONDS. If the record cannot be written the call fails. POST /v1/chat/completions with "stream": true is passed through chunk by chunk and recorded as the raw SSE bytes when it ends: usage from the final usage chunk (stream_options.include_usage), finish_reason, tool names from the deltas. A stream cut by a client disconnect, an upstream break or SEALEDRUN_PROXY_MAX_BODY_BYTES is recorded with sealedrun.proxy.truncated and outcome error. POST /v1/responses with "stream": true is passed through the same way; usage, status or incomplete reason and tool names come from the terminal response.completed / response.incomplete / response.failed event, and response.failed gives outcome error.
  • Recorder LLM proxy, Anthropic Messages wire format: POST /v1/messages, POST /v1/messages/count_tokens, and GET /v1/models in the Anthropic shape when anthropic-version is sent. Works with Claude Code and the Anthropic SDKs through ANTHROPIC_BASE_URL (token as x-api-key or bearer); anthropic-version and anthropic-beta are passed through. Recorded input_tokens include cache writes and reads. "stream": true is passed through event by event and recorded as the raw SSE bytes: the model and input tokens from message_start, tool names from content_block_start, stop reason and output tokens from message_delta; an error event gives outcome error, a stream cut before message_stop is truncated.
  • Recorder LLM proxy, Ollama native wire format: POST /api/chat, /api/generate, /api/embed, /api/embeddings, plus unrecorded GET /api/tags, GET /api/version and POST /api/show. Model management endpoints are not proxied. /api/chat and /api/generate stream by default (NDJSON) and are passed through line by line; the done: true line gives the counts and done reason, a stream cut before it is truncated, a line with error gives outcome error.
  • Recorder LLM proxy, Gemini native wire format: POST /v1beta/models/{model}:generateContent, :countTokens, :embedContent, :batchEmbedContents (also under /v1), plus unrecorded GET /v1beta/models and GET /v1beta/models/{model}. The token is accepted as x-goog-api-key or ?key=; the query key is never forwarded or recorded. Recorded output_tokens include thinking tokens. :streamGenerateContent?alt=sse is passed through chunk by chunk (other alt values are refused); finish reason and usage come from the last chunk, function call names from any chunk.
  • POST /api/runs/{run_id}/export exports a live run as a bundle signed by the recorder: its agent key is the exporter, its principal key countersigns. An open run exports with complete: false; ?end=true writes run_end first. Imported runs answer 409 (download the original bundle). A payload body the database no longer holds is a 500, never a silent gap.
  • The recorder reports its own Principal as trusted on the runs it signed itself (principal_trusted in /api/runs), so live runs need no SEALEDRUN_TRUSTED_PRINCIPALS entry. Bundles from other Principals are unaffected.
  • Inspector, “Runs in the recorder” tab: live and imported runs are badged, the list and the open live run refresh every 5 s while the tab is visible, and “Export bundle” / “Close run and export” download the bundle and verify it in the browser.
  • Test vector spec/vectors/bundle/open-run.zip: a run exported before run_end; verifiers accept it and report complete: false (SPEC 14).
  • Claude Code on a subscription login through the proxy: the recorder token is also accepted in X-SealedRun-Token (set with ANTHROPIC_CUSTOM_HEADERS), and an upstream with client_auth: passthrough receives the client’s own Authorization / x-api-key instead of its configured key, with anthropic-beta as sent. Other upstreams keep swapping in their key; the client credential and the recorder token are never recorded, and the recorder token is never forwarded.
  • README: Codex through the proxy (Responses API provider), and the Ollama context length that coding agents need.
  • The proxy forwards the upstream response headers SDKs act on: for the Anthropic format x-should-retry, retry-after, request-id and anthropic-ratelimit-* (what Claude Code reads through a gateway); for the OpenAI format retry-after, x-request-id and x-ratelimit-*. On plain, error and streamed replies alike. Every other upstream header stays behind the proxy.
  • Docker image reads upstreams from /data/upstreams.yaml.
  • /api/runs and /api/runs/{run_id} carry run_label, the X-SealedRun-Run label a live run was opened with (null otherwise); the Inspector shows it in the run list. Live-test finding: clients had no way to find their run.
  • Streamed calls ask the upstream for accept-encoding: identity. Live-test finding: api.anthropic.com gzips SSE when allowed, and the relay passed the compressed bytes on without content-encoding, so the Anthropic SDK failed on messages.stream(); buffered calls were decoded and unaffected. The recorded stream bytes are now always plain SSE.
  • README: Docker + Ollama on one host (host networking), the upstreams file is read once at start, unroutable calls leave no record. upstreams.example.yaml names the common untagged Ollama embedding models, which "*:*" does not match.
  • SPEC.md 10.3: registered extension sealedrun.proxy (upstream, dialect, operation, HTTP status, latency, truncated, run label).

[0.1.1] - 2026-09-21

First public release. Security hardening before publication. The project and the record format are now named SealedRun (previously AFR).

Security

  • Trust anchor. A bundle signed end to end by a newly minted Principal used to verify exactly like a genuine one, and the report did not even name the Principal. SPEC.md 13.2 now has a trust anchor step and TRUST.md no longer treats the keys inside a bundle as a root of trust. verify_bundle / verifyBundle take the trusted principal ids and fail with check trust on anyone else; BundleReport carries principal_id, the exporter agent_id and principal_trusted. The recorder has SEALEDRUN_TRUSTED_PRINCIPALS; the Inspector shows the principal, has a box for trusted ids and says “integrity only, signer not authenticated” instead of “verified” when none matched. API change: BundleReport has three new required fields.
  • Verifiers (Python and TypeScript) reject every Delegation whose principal_id is a DID: 0.1 has no DID resolver, so such an identifier was never bound to the signing keys. SPEC.md and TRUST.md say so.
  • Bundle verification checks every delegation in the bundle and requires its principal_id to equal the manifest principal_id; the principal countersignature is checked against the exporter’s delegation in both implementations.
  • Bundle readers enforce decompression limits (64 MiB per entry, 512 MiB total, 10 000 entries) and report malformed archives with a fixed message instead of exception text.
  • Anchors: receipt.digest is now required and must equal anchored_hash, and an anchors/<record_id>.json file must be the receipt of that anchor record, in both verifiers. Until now neither implementation looked at a receipt at all. The witness proof itself is still not verified offline: run reports carry anchors_witness_verified (0 in 0.1), the Inspector says so, and TRUST.md states the limitation.
  • Schemas bound every uniqueItems array with maxItems (delegations and runs 1024, data_labels 64), manifest.json is capped at 4 MiB, and the Python verifier stops at the first schema error: a few-kilobyte upload could previously block the recorder for minutes in a quadratic uniqueness check.
  • ed25519 public keys must be canonical points of prime order, and R must be canonical and not the identity, in both verifiers. Previously a key holder could publish a torsion-shifted key, or sign with a mixed-order R, and get bundles that @sealedrun/core accepted and the Python verifier rejected.
  • es256 signatures are low-S only: the Python signer normalises s and both verifiers reject high-S. Previously about half of the aat-compat-1 objects signed in Python were rejected by @sealedrun/core.
  • @sealedrun/core validates the structure of manifests, delegations and records before use.
  • Recorder listens on 127.0.0.1 by default; compose publishes the port to localhost only. Optional SEALEDRUN_API_TOKEN protects every API route except /api/health (Bearer).
  • Recorder refuses state-changing requests whose Sec-Fetch-Site is not same-origin/same-site; without that header a valid bearer token is required. TrustedHostMiddleware limits the accepted Host names (SEALEDRUN_ALLOWED_HOSTS). The token is accepted as Bearer only and no WWW-Authenticate: Basic challenge is sent, so a browser holds no credential to replay; the Inspector asks for the token and sends it itself.
  • Payload downloads are served as attachments with nosniff, a sandbox CSP and a media type allow-list.
  • No default PostgreSQL password. Docker base images and GitHub Actions are pinned by digest; Dependabot keeps them current; workflow token is read-only by default.

Changed

  • Inspector redesigned: Tailwind CSS 4, self-hosted IBM Plex, a verdict block that separates “verified” from “intact, signer not confirmed”, a welcome state that explains what to do with a “Try the example bundle” link, the run shown as a linked chain of steps, a proper token form, phone layout and keyboard focus.
  • Renamed everything from AFR to SealedRun: packages sealedrun, sealedrun-recorder, @sealedrun/core, env prefix SEALEDRUN_, field spec_version, extension namespace sealedrun.*, signing domains sealedrun/*/v1. Vectors and examples regenerated.
  • List endpoints are paginated (limit, offset); the bundle archive column is loaded only on download.
  • JSON Schemas ship inside the sealedrun wheel.
  • Public Python API carries PEP 257 docstrings and exported TypeScript symbols carry TSDoc; both are enforced by the linters (ruff D, eslint-plugin-jsdoc, eslint-plugin-tsdoc).

Fixed

  • The sealedrun wheel could not be built from its own sdist: the schema directory was a forced include that only exists in the repository. A build hook now handles both cases.

Added

  • SECURITY.md, CODE_OF_CONDUCT.md, .github/dependabot.yml.
  • Release workflow: a v* tag builds the packages, installs them into a clean environment to verify the example bundle, and publishes to PyPI and npm through trusted publishing (OIDC, no stored tokens) behind a manually approved environment.

[0.1.0] - 2026-09-17

Stage 0: specification, reference implementations, inspector, Docker image.

Added

  • Monorepo skeleton: Python workspace (sealedrun, sealedrun-recorder), pnpm workspace (@sealedrun/core, @sealedrun/web), GitHub Actions CI with GHCR image publishing.
  • Licensing: Apache-2.0 for code and schemas, CC-BY-4.0 for the specification text, CLA.
  • SPEC.md: SealedRun 0.1 draft (entities, RFC 8785 canonicalization, hash chain, hybrid Ed25519 + ML-DSA-65 signatures, delegation, tombstones, anchors, bundle format, mappings to SEP-3004, IETF AAT, OTel GenAI, EU AI Act Art. 12/19/26).
  • TRUST.md: threat model and claims a bundle does and does not support.
  • JSON Schema (draft 2020-12) in spec/schema/: common.json, record.json, delegation.json, bundle.json, extensions/sealedrun.json; sealedrun.schema loader with tests.
  • Python reference library sealedrun: RFC 8785 canonicalization, SHA-256/384 hash chain, hybrid key sets (Ed25519 + ML-DSA-65, ES256 + ML-DSA-65, Ed25519 + ML-DSA-87), domain-separated signing, delegations, RunWriter, run verifier with exact failure location, bundle writer/reader/verifier.
  • Test vectors in spec/vectors/ (keys, RFC 8785 set, delegation, valid run, nine negative chains, four bundles) generated by sealedrun.vectors; worked examples in spec/examples/.
  • TypeScript library @sealedrun/core: independent canonicalization, hashing, hybrid signature verification, run and bundle verification; passes all spec/vectors/ (27 tests).
  • sealedrun-recorder service (FastAPI + SQLAlchemy, SQLite by default, PostgreSQL via SEALEDRUN_DATABASE_URL): bundle upload with verification and import, verify-only endpoint, runs/records/payload API, serves the web UI when built.
  • Web inspector (apps/web, Next.js static export): drag-and-drop bundle verification in the browser, step feed with target, location, labels, policy decision and payload preview, browsing of runs stored in the recorder.
  • Docker: multi-stage Dockerfile (Node builds the UI, uv builds the Python env, slim runtime as non-root user with healthcheck, about 230 MB), docker-compose.yml with SQLite default and a postgres profile, .env.example.
Last updated on