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 andsealedrun-mcp-wrapon 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: ahuman_approvalrecord now carries the human’sdecision(granted/denied) and, as its payload, the request the human was shown. Both verifiers bind it to the step that then ran underpolicy.decision = "require_approval"bypayload.request_hashand reportapproval.missing(no approval for that request before the step) andapproval.denied_but_executedas findings; one approval covers one execution.strict_approval/strictApprovaland the CLI flag--strict-approvalturn the first finding into a failure. SDKapprove/approverecords the answer. Vectorsspec/vectors/approval/. Inspector shows the human decision on the record and counts approval findings in the verdict. Records from earlier versions verify as before: ahuman_approvalwithout 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 passessystem_prompt(SDK optionsystem_prompt/systemPrompt) and the recorder stores the digest only. Both verifiers reportsystem_prompt_changes/systemPromptChanges, the seq of every model call whose instructions differ from the previous one’s; the CLI printssystem prompt changed, the Inspector tags the step and shows the digest. Vectorspec/vectors/llm/system-prompt-change.jsonl. Mapped to OTelgen_ai.system_instructionsin SPEC 11. scripts/demo_tamper.pyedits one field of one record inside a bundle so anyone can watch the verifier refuse it;scripts/render_demo.pyrecords that asdocs/demo-tamper.gifand.mp4from 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.scopenow have a defined meaning (allowed_targetswith*globs onname,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/strictScopeand the CLI flag--strict-scopeturn the first finding into a failure. The CLI prints the findings per run. Vectorsspec/vectors/scope/. The recorder writesSEALEDRUN_SCOPEinto 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 aproxyreceipt on every relayed call (LLM, MCP, A2A) from the reply it saw; SDKreceipt_for/receiptForand thereceiptoption ofrecordattach aself_reportedone, and a posted step claimingproxyis refused. Both verifiers fail with checkreceiptwhen a proxy receipt sits on a record the recorder did not relay, is not anhttp_response, or its digest differs frompayload.response_hash, and count accepted receipts by observer; the CLI prints the counts. Vectorsrecords/receipt-run.jsonland 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_hashinsealedrun.delegation). The recorder’s A2A proxy announces its record to the callee in theX-SealedRun-Parentheader 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 byverify_bundle): the asking record must exist and be atool_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. Vectorsbundle/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
proxyreceipt 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.completionandspan.input/span.output, the stringgen_ai.response.finish_reasonis read when the array is absent,decisionsspans becomellm_callrecords withsealedrun.decisionbuilt from the completion, andopenrouterandtypesafecount 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, modelgpt-6-luna) is forwarded and recorded as anllm_callwith operationdecisions; token usage andmodelare read when the reply carries them, arefusalanswer setsfinish_reason: refusal. The policy rule applies as to any cloud call. Streaming is refused with 400. - Recorder proxy: dialect
systemonefor decision models in the TypeSafe wire format (OpenRoutertypesafe/jev-*, Microsoft Foundry Decision-1). The recorder acceptsPOST /api/alpha/decisions,/api/v1/systemoneand/providers/microsoft/v1/systemone, forwards to<upstream url>/systemoneand records anllm_callwith operationdecisions, the model snapshot and token counts.api-keyand bearer credentials pass through as for other dialects. SPEC 10.3 and the extension schema list the new dialect;upstreams.example.yamlhas entries for OpenRouter and Foundry. - SPEC 10.7
sealedrun.decision: the recorder extracts a decision model’s answers from the reply of adecisionscall 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; vectorrecords/decision-run.jsonl. - SPEC 10.8
sealedrun.gateand SDKrecord_gate/recordGate(py, ts): a self-reportedpolicy_decisionstep that names the decision call byparent_record_idand states the thresholds and the outcome (approve,block,review). The SDKs’recordtakesparent_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
decisionandapprove/block/review; adecisionsfilter chip keeps decision calls and their gates; the text filter matchesdecisionand the verdict.
Fixed
sealedrun-pytests: 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_ANCHORSselects the anchors (tsa,rekor) in the order tried; anchoring is now ON by default with Sigstore’s time-stamp authority (anchor_tsa_urldefaults to it,anchor_rekor_urlto the public Rekor log),[]turns it off; DigiCert’s authority is the default fallback.GET /api/identityreportsanchors(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 andanchorsstayed 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/runstakesq,since,until,complete,source;GET /api/runs/{id}/recordstakeskind,outcome,q; both answerX-Total-Count. -
Export without payload bodies:
POST /api/runs/{id}/export?payloads=omitand the Inspector button “Export without payloads” write the records, signatures and receipts only, and the signed manifest sayspayloads: "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, reportpayloads_omitted, and refuse a bundle that says omitted yet carries a body. Vectorsbundle/payloads-omitted.zipandpayloads-omitted-with-bodies.zip. -
GET /api/runs/{id}reportspayload_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_bundleaccepts 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 Nsets 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>(orSEALEDRUN_AGENT) sendsX-SealedRun-Agentwith 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’stools/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.contexton everyrun_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 everypolicyblock aspolicy_set_hash, SPEC 5.7) and, when the run opens on an MCPtools/list, the tool inventory the agent saw (tool_inventory_hashover thetoolssorted by name,tool_inventory_server). The agent names itself withX-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_metaclientInfo or thegen_ai.agent.*span attributes;agent_sourcesays 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 checkcontextwhen the extension sits on a record other thanrun_startor apolicyblock names another policy set than the run’s. PythonRecorder(agent=...)and TypeScriptRecorderOptions.agentset the header. Inspector shows a “What produced this run” card and says whether the run is comparable with the one shown before it. New vectorsrecords/context-run.jsonl,chains/policy-set-hash-mismatch.jsonl,chains/context-on-llm-call.jsonl;two-runs.zipandduplicate-record-id-across-runs.zipregenerated with the context on their second run.
Changed
- Recorder: SQLAlchemy 2.0.54 -> 2.1.3 (
uv.lock; the floor inpyproject.tomlstays>=2.0). 2.1 flushes pending rows before every statement, typesRowper column and drops greenlet from the default install. The recorder only reads throughSession.getwhile rows are pending, which flushed already in 2.0, uses the sync engine only and has nofilter_byortext()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_idmay not repeat across the runs of a bundle (SPEC 5.1, 13.2 step 3).verify_bundle/verifyBundlefail with checkrecord_idat the later record, runs taken in manifest order; until now only a repeat inside one run was refused, whileanchors/<record_id>.jsonnames an anchor record by that id alone, so two runs sharing an id left it open which record a receipt file belonged to. New vectorsspec/vectors/bundle/duplicate-record-id-across-runs.zipand, 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/runscall 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 undersealedrun-mcp-wrap, models on Ollama or OpenAI through the proxy, its own tests; root README section “Five minutes to a verified run”.docker-compose.ymlmounts the rootupstreams.yaml(Ollama on the host, OpenAI withOPENAI_API_KEY). End-to-end recorder testtest_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
rfc3161verifier adds a receipt’schaincertificates 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 speltCN=Fake/ issuerCN=fakeslipped 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 vectorsspec/vectors/anchors/hostile.json(generatorpackages/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), arekoranchor shows the log’sintegrated_timeand never agen_timewritten 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’switnessorlog_url(also removes a quadratic regex on that field). -
Security pass, recorder and CLI: requests carrying an
X-Forwarded-HostoutsideSEALEDRUN_ALLOWED_HOSTSare refused (400), so the Next dev server’s/apirewrite, 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%252eand..;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’starget.endpoint);python -m sealedrunreplaces control characters in what it prints (an archive path could carry a terminal escape that repaintsFAILasOK), and the manifestfilespattern allows printable ASCII only. -
Data labels from the caller:
X-SealedRun-Labels: nda, piion LLM, MCP HTTP and A2A proxy calls and onPOST /api/steps(when the body names none) becomesdata_labelswithsealedrun.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 withlocation: cloudbefore any upstream contact (403 in the dialect’s error shape; JSON-RPC error40003for MCP and A2A,PERMISSION_DENIEDon the A2A REST binding) and seals a record withoutcome: blocked,policy(recorder/no-<label>-to-cloud,block, reason) and the request digest; labelled cloud calls the rule allows carrypolicydefault/allow. Coverstools/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-siteis no longer accepted for state-changing API calls or MCP proxy calls, onlysame-origin, so a sibling subdomain cannot post to a token-less recorder. -
Security pass, Inspector and UI headers: the public log link of a
rekoranchor is offered only when the receipt verified offline and itslog_urlishttps:; 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 sendsContent-Security-Policy(self only, no framing),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy,Cross-Origin-Opener-PolicyandCross-Origin-Resource-Policyon every response, UI and API. -
Security pass, verifier robustness:
read_bundle/readBundlefail with aVerificationError(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_hashandcheck_signaturesreturn False instead of raising on values RFC 8785 cannot express; arecord_idthat repeats within a run fails verification (record_id, new vectorchains/duplicate-record-id.jsonl); a run whosehash_algdiffers from the manifest fails (hash_alg); arekorreceipt counts as witness-verified only with a present, plausibleintegrated_timeinside the witness validity (SPEC 8.1.2); a huge log time no longer raises. Both verifiers. -
Security pass, clients and wrapper: the Python
Recorderclient never follows a redirect (RecorderErrorwith the 3xx status) and the TypeScript client sendsredirect: "error", so the bearer token cannot be lured to another host;sealedrun-mcp-wrapdrops--token(visible in/proc/<pid>/cmdline) in favour ofSEALEDRUN_TOKENor--token-file, matches integral float ids (1.0) to their requests, and in--strictmode 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/stepscarriessealedrun.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 carryingsealedrun.proxy,sealedrun.otel,sealedrun.anchor,sealedrun.delegation,sealedrun.importedorsealedrun.tombstoneare refused (400), as aresealedrun.labelssources other thanmanual/header.sealedrun-mcp-wrapnow writessealedrun.step(via: sealedrun-mcp-wrap, latency, truncation) instead ofsealedrun.proxy. Run summaries gainlabels_self_reported: labels on self-reported cloud-target steps, counted apart fromlabels_sent_to_cloud, which now covers only records the recorder made itself (the runs table gains that column: delete an oldsealedrun.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 aLiveRunErrorinstead of a silent chain split; target text is bounded before sealing (type16,name256,location16,endpoint2048,provider256 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 againstSEALEDRUN_PROXY_MAX_BODY_BYTESas it arrives, so a chunked upload withoutContent-Lengthis 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 (truncatedfor 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/listdigests to 4096 keys, anchoring forgets idle run locks. OTLP exports with more than 2000 spans are refused (413).SEALEDRUN_MAX_BUNDLE_BYTESdefaults to 64 MB (was 256) and an uploaded archive may inflate to at most four times the cap.sealedrun-mcp-wrapkeeps 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--timeoutin total. -
Security pass, proxy governance: the MCP proxy refuses JSON-RPC batches, requests whose
idis not a string or an integer, andtools/call/resources/read/prompts/get/tools/listsent without anid(400, never forwarded), since none could be recorded or governed. The A2A proxy records and governs every POST, PUT and PATCH to acloudagent, whatever its method or sub-path (sealedrun.a2a.methodnames it), refuses cloud requests it cannot read (400) and batches or bad ids for every agent, recognises the A2A 0.3 namesmessage/send,message/stream,tasks/cancel, and refuses sub-paths with./..segments or percent-encoded delimiters.SEALEDRUN_POLICY_BLOCK_TO_CLOUDlabels are validated against the SPEC 5.6 pattern at start. Proxy-generated JSON-RPC error codes moved from-32000/-32003to the application codes40000/40003(the MCP specification reserves-32000..-32099). -
Python verifier checks
rfc3161anchor receipts offline (sealedrun.anchors.rfc3161, onrfc3161-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_timeinside the entry’s validity;verify_runandverify_bundletakewitnesses(default: the shippedsealedrun/trust/witnesses.jsonwith the Sigstore TSA, DigiCert Trusted Root G4 and the Rekor v1 log key) andstrict_witness.anchors_witness_verifiedcounts 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_frombuild 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 incases.json; the reference run’s anchor is now a real Sigstore time-stamp. -
TypeScript verifier checks
rfc3161anchor receipts offline on WebCrypto (verifyRfc3161, onpkijs):verifyRunAsync,verifyBundleAsyncandverifyWitnessesfillanchorsWitnessVerified(the synchronousverifyRun/verifyBundleleave it at 0);shippedWitnesses()is the same trust list as the Python package,witnessesandstrictWitnessoptions 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_URLset (for examplehttps://timestamp.sigstore.dev/api/v1/timestamp, optionalSEALEDRUN_ANCHOR_TSA_FALLBACK_URLsuch ashttp://timestamp.digicert.com), a background task time-stamps the head of every open live run whose head moved, everySEALEDRUN_ANCHOR_INTERVAL_SECONDS(600), andPOST /api/runs/{id}/exportanchors the head first, beforerun_endwithend=true. Each receipt becomes a signedanchorrecord (rfc3161, with the authority’s published chain when it has a/certchainendpoint); a failing authority is retried once, then the fallback is tried, then the run goes on unanchored with a log line. Run summaries gainlast_anchor_at; the runs table gains that column (delete an oldsealedrun.db). -
Sigstore Rekor v1 anchors: the recorder keeps a P-256 anchoring key (
keys/anchor.pem, published asanchor_public_keyinGET /api/identity) and, withSEALEDRUN_ANCHOR_REKOR_URLset (for examplehttps://rekor.sigstore.dev), publishes every anchored head as ahashedrekordentry and writes a secondanchorrecord of typerekorwith 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_runcounts verifiedrekoranchors. 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
rekorreceipts offline (verifyRekoron@noble/curvesP-256, browser-safe): log key bylog_id, entry body, anchoring signature, signed entry timestamp, RFC 6962 inclusion path and the C2SP checkpoint;verifyReceiptdispatches by anchor type andverifyRunAsync/verifyBundleAsynccount 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) andrekor(Sigstore Rekor v1hashedrekordentry 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_verifiednow means anchors verified against a witness in the trust list. -
Recorder MCP proxy for Streamable HTTP servers:
POST,GETandDELETE /mcp/<name>, servers listed undermcp_serversin the upstreams file (URL,key_envwithauthbearer/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 withX-Accel-Buffering: no; cross-site browser calls are refused.tools/call,resources/read,prompts/getandtools/listaccepted by the server becometool_callrecords in the same runs as the LLM calls (X-SealedRun-Run); a JSON-RPC error orisErrorgives outcomeerror,resultType: input_requiredgivespending, a stream that ends before the response istruncated.tools/listis 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.mcpgainsprotocol_version,result_typeandsession(a SHA-256 of the session id);sealedrun.proxy.dialectgainsmcp. -
sealedrun-mcp-wrap(in thesealedrunpackage, standard library only): wraps an MCP stdio server, relays stdin/stdout unchanged, forwards signals and the exit code, and recordstools/call,resources/read,prompts/getandtools/listastool_callrecords withtransport: stdioby 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;--strictanswers 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 TypeScriptRecorder(record,step,wrap) in@sealedrun/core, both posting toPOST /api/steps;RecorderErroron refusal or an unreachable recorder. -
Recorder A2A proxy:
/a2a/<name>for A2A 1.0 agents listed undera2a_agentsin the upstreams file (JSON-RPC and REST bindings, SSE relayed chunk by chunk,A2A-Version/A2A-Extensionspassed through), agent card served with its interface URLs rewritten to the proxy.SendMessage,SendStreamingMessageandCancelTaskbecometool_callrecords with the newsealedrun.a2aextension (SPEC 10.4) and outcome by task state;sealedrun.proxy.dialectgainsa2a. 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 bygen_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 statusERROR-> outcomeerror; invalid spans reported inpartial_success; re-sent spans recorded once.sealedrun.otelgains optionaloperation,span_name,started_at,duration_ms,scope,agent,conversation. Tested with the OpenTelemetry SDK and a LangGraph agent on Ollama traced byopentelemetry-instrumentation-langchain. -
Recorder self-report endpoint
POST /api/steps(label inX-SealedRun-Run) andPOST /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 inheritSEALEDRUN_TOKEN.tools/liststeps 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 fromupstreams.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.yamlcovers OpenAI, Azure OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Qwen, GLM, MiniMax, Mistral, xAI, Groq, OpenRouter, Ollama, LM Studio, vLLM and llama.cpp. The client sendsSEALEDRUN_API_TOKENwherever its SDK puts the API key and the upstream key is swapped in server side. Every call becomes a signedllm_callrecord with request and response bodies, never headers. Calls with the sameX-SealedRun-Runlabel share a run; unlabelled calls share a run until it is idle forSEALEDRUN_PROXY_RUN_IDLE_SECONDS. If the record cannot be written the call fails.POST /v1/chat/completionswith"stream": trueis passed through chunk by chunk and recorded as the raw SSE bytes when it ends: usage from the finalusagechunk (stream_options.include_usage),finish_reason, tool names from the deltas. A stream cut by a client disconnect, an upstream break orSEALEDRUN_PROXY_MAX_BODY_BYTESis recorded withsealedrun.proxy.truncatedand outcomeerror.POST /v1/responseswith"stream": trueis passed through the same way; usage, status or incomplete reason and tool names come from the terminalresponse.completed/response.incomplete/response.failedevent, andresponse.failedgives outcomeerror. - Recorder LLM proxy, Anthropic Messages wire format:
POST /v1/messages,POST /v1/messages/count_tokens, andGET /v1/modelsin the Anthropic shape whenanthropic-versionis sent. Works with Claude Code and the Anthropic SDKs throughANTHROPIC_BASE_URL(token asx-api-keyor bearer);anthropic-versionandanthropic-betaare passed through. Recordedinput_tokensinclude cache writes and reads."stream": trueis passed through event by event and recorded as the raw SSE bytes: the model and input tokens frommessage_start, tool names fromcontent_block_start, stop reason and output tokens frommessage_delta; anerrorevent gives outcomeerror, a stream cut beforemessage_stopistruncated. - Recorder LLM proxy, Ollama native wire format:
POST /api/chat,/api/generate,/api/embed,/api/embeddings, plus unrecordedGET /api/tags,GET /api/versionandPOST /api/show. Model management endpoints are not proxied./api/chatand/api/generatestream by default (NDJSON) and are passed through line by line; thedone: trueline gives the counts and done reason, a stream cut before it istruncated, a line witherrorgives outcomeerror. - Recorder LLM proxy, Gemini native wire format:
POST /v1beta/models/{model}:generateContent,:countTokens,:embedContent,:batchEmbedContents(also under/v1), plus unrecordedGET /v1beta/modelsandGET /v1beta/models/{model}. The token is accepted asx-goog-api-keyor?key=; the query key is never forwarded or recorded. Recordedoutput_tokensinclude thinking tokens.:streamGenerateContent?alt=sseis passed through chunk by chunk (otheraltvalues are refused); finish reason and usage come from the last chunk, function call names from any chunk. POST /api/runs/{run_id}/exportexports a live run as a bundle signed by the recorder: its agent key is the exporter, its principal key countersigns. An open run exports withcomplete: false;?end=truewritesrun_endfirst. 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_trustedin/api/runs), so live runs need noSEALEDRUN_TRUSTED_PRINCIPALSentry. 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 beforerun_end; verifiers accept it and reportcomplete: false(SPEC 14). - Claude Code on a subscription login through the proxy: the recorder token is also accepted in
X-SealedRun-Token(set withANTHROPIC_CUSTOM_HEADERS), and an upstream withclient_auth: passthroughreceives the client’s ownAuthorization/x-api-keyinstead of its configured key, withanthropic-betaas 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-idandanthropic-ratelimit-*(what Claude Code reads through a gateway); for the OpenAI formatretry-after,x-request-idandx-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/runsand/api/runs/{run_id}carryrun_label, theX-SealedRun-Runlabel a live run was opened with (nullotherwise); 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 withoutcontent-encoding, so the Anthropic SDK failed onmessages.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.yamlnames the common untagged Ollama embedding models, which"*:*"does not match. SPEC.md10.3: registered extensionsealedrun.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.md13.2 now has a trust anchor step andTRUST.mdno longer treats the keys inside a bundle as a root of trust.verify_bundle/verifyBundletake the trusted principal ids and fail with checktruston anyone else;BundleReportcarriesprincipal_id, the exporteragent_idandprincipal_trusted. The recorder hasSEALEDRUN_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:BundleReporthas three new required fields. - Verifiers (Python and TypeScript) reject every Delegation whose
principal_idis a DID: 0.1 has no DID resolver, so such an identifier was never bound to the signing keys.SPEC.mdandTRUST.mdsay so. - Bundle verification checks every delegation in the bundle and requires its
principal_idto equal the manifestprincipal_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.digestis now required and must equalanchored_hash, and ananchors/<record_id>.jsonfile 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 carryanchors_witness_verified(0 in 0.1), the Inspector says so, andTRUST.mdstates the limitation. - Schemas bound every
uniqueItemsarray withmaxItems(delegationsandruns1024,data_labels64),manifest.jsonis 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. ed25519public keys must be canonical points of prime order, andRmust be canonical and not the identity, in both verifiers. Previously a key holder could publish a torsion-shifted key, or sign with a mixed-orderR, and get bundles that@sealedrun/coreaccepted and the Python verifier rejected.es256signatures are low-S only: the Python signer normalisessand both verifiers reject high-S. Previously about half of theaat-compat-1objects signed in Python were rejected by@sealedrun/core.@sealedrun/corevalidates the structure of manifests, delegations and records before use.- Recorder listens on
127.0.0.1by default; compose publishes the port to localhost only. OptionalSEALEDRUN_API_TOKENprotects every API route except/api/health(Bearer). - Recorder refuses state-changing requests whose
Sec-Fetch-Siteis notsame-origin/same-site; without that header a valid bearer token is required.TrustedHostMiddlewarelimits the accepted Host names (SEALEDRUN_ALLOWED_HOSTS). The token is accepted as Bearer only and noWWW-Authenticate: Basicchallenge 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 prefixSEALEDRUN_, fieldspec_version, extension namespacesealedrun.*, signing domainssealedrun/*/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
sealedrunwheel. - 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
sealedrunwheel 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.schemaloader 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 bysealedrun.vectors; worked examples inspec/examples/. - TypeScript library
@sealedrun/core: independent canonicalization, hashing, hybrid signature verification, run and bundle verification; passes allspec/vectors/(27 tests). sealedrun-recorderservice (FastAPI + SQLAlchemy, SQLite by default, PostgreSQL viaSEALEDRUN_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.ymlwith SQLite default and apostgresprofile,.env.example.