SealedRun
A tamper-evident, signed record of every step an AI agent takes. Every step an agent takes — model call, MCP tool call, memory access, policy decision, human approval — becomes a signed record in an append-only hash chain. Records are exported as an evidence bundle that anyone can verify offline, without trusting the operator.

Change one field of one record and python -m sealedrun refuses the bundle. Reproduce it with
python scripts/demo_tamper.py on spec/examples/bundle.zip.
The signing keys live in the recorder, a separate process, not in the agent: the model and the agent code never hold them, so an agent cannot write or rewrite its own record. The SDKs and the MCP wrapper only report steps to the recorder; they carry no key. TRUST.md states the deployment this depends on.
Status: early development. 0.8.1 records the name and version each MCP server reports about
itself (sealedrun.mcp.server_info) and adds an AIUC-1 evidence map and a Langfuse example.
0.8.0 answers two more auditor questions: whether a required human
approval was actually given before the step (verifier findings approval.*) and which system
prompt was in force at each model call (system_prompt_hash). 0.7.0 records every channel of an agent: LLM proxy (OpenAI, Anthropic,
Ollama and Gemini wire formats), decision models (OpenAI Decisions, OpenRouter Jev and Microsoft
Decision-1 in the systemone format, with the verdict and the gate that acted on it in the record),
MCP servers (HTTP proxy and stdio wrapper), A2A agents, self-reported steps from the SDKs and
OpenTelemetry GenAI spans. Run heads are anchored by default in Sigstore’s
time-stamp authority (DigiCert as fallback, Rekor on request) and both verifiers check the receipts
offline. Big runs export without payload bodies and stream to disk; the Inspector pages and
searches runs and steps.
Why
Agent observability tools store traces in databases the operator can edit. When a regulator, insurer or counterparty asks “what did your agent send to which model, and who allowed it”, an editable log is a claim, not evidence. SealedRun turns each step into a signed link of a hash chain bound to a delegation from the accountable party, anchored in an external witness, and exported as a bundle that any third party verifies offline.
How it works
- A Principal (the accountable organisation) signs a Delegation for an Agent key set. “Agent” here names the signing identity of one deployed agent; the recorder holds its keys, the model and the agent code do not.
- Every step of a Run becomes a Record: kind, target and its location (local or cloud),
payload digests, data labels, policy decision and reason,
prev_hash,hash, and a hybrid Ed25519 + ML-DSA-65 signature the recorder makes with the Agent keys. - The chain head is periodically anchored in an external witness (Sigstore Rekor, RFC 3161).
- Records, delegations, optional payload bodies and a signed manifest are exported as a Bundle. The verifier recomputes everything and reports the first failing check and seq.
Read SPEC.md for the format and TRUST.md for what a bundle proves.
What the verifier answers
A signed, chained log shows that nobody edited it afterwards. An auditor asks five more
questions, and python -m sealedrun bundle.zip answers them without reading the records:
- Did the agent stay within its mandate? The Principal writes what the agent may touch into
the delegation
scope(targets with*masks, locations, denied labels, record budget). The verifier checks every record against it and lists each step outside it with its seq and check (scope.target,scope.location,scope.label,scope.max_records). Findings do not break verification;--strict-scopemakes the first one fail it. - Was the required human approval actually given? When a policy answers
require_approval, the application records the human’s answer withapprove(...), passing the very request the human saw. The verifier binds that record to the step that then ran by the request digest and lists every step that ran without one, after a denial, or on a different request (approval.missing,approval.denied_but_executed).--strict-approvalmakes the first one fail. - Which instructions were in force? Every proxied model call carries
sealedrun.llm.system_prompt_hash, the digest of the system instructions the recorder saw in the request (OpenAI, Anthropic, Ollama, Gemini shapes); self-reported calls passsystem_promptand the recorder keeps only the digest. The verifier lists the step at which the instructions changed within a run (system prompt changed), so an auditor can tell a run under prompt v2 from one under v1 without reading a body. - Does the changed system confirm the step? Each proxied call carries a
proxyreceipt the recorder wrote from the upstream’s reply (digest, status, host). Steps reported from your own code attach what the target returned (commit sha, file digest, transaction id) as aself_reportedreceipt. The verifier checks the receipts against the records and counts them by observer; the auditor comparesrefanddigestwith the target system’s own log. - Who allowed it? Every run is bound to a Principal-signed delegation, and the report says whether that Principal matched a trusted id obtained out of band.
- Is the chain across agents one chain? When agent A asks agent B over the A2A proxy, the
recorder tells B which record asked (
X-SealedRun-Parent), B’s recorder writes it into the run it opens, and the verifier binds the two runs: the asking record exists, B started after A’s run, B’s delegation is within A’s scope. A → B → C → D becomes one provable sequence, and a parent held in another bundle is reported as unresolved until both are verified together.
OK bundle 0192b3c4-…: 1 run(s), principal trusted
run 0192b3c4-…: 9 records, complete, anchors 1, witness verified 1, scope findings 1, receipts 7 proxy / 1 self-reported
seq 5 scope.target: target filesystem/write_file is not in allowed_targetsSet the scope on the recorder with SEALEDRUN_SCOPE (JSON; the delegation is reissued when it
changes), for example {"allowed_locations":["local"],"denied_labels":["pii","secret"]}.
TRUST.md says what a receipt proves and what it does not.
Five minutes to a verified run
git clone https://github.com/sealedrun/sealedrun && cd sealedrun
SEALEDRUN_API_TOKEN=change-me docker compose up -d # recorder + Inspector on :8080
cd examples/langgraph-mcp && uv sync && SEALEDRUN_TOKEN=change-me uv run agent.pyThe agent (LangGraph create_agent) asks a model what notes.txt is about; the model goes
through the recorder’s proxy and the filesystem MCP server runs under sealedrun-mcp-wrap, so
model and tool calls land in one run. Export it and verify it with the CLI, @sealedrun/core
or the Inspector at http://127.0.0.1:8080 ; the walk-through is in
examples/langgraph-mcp . The compose file mounts
upstreams.yaml (Ollama on the host at host.docker.internal, which needs
OLLAMA_HOST=0.0.0.0, and OpenAI with OPENAI_API_KEY); edit it for your providers.
Already tracing with Langfuse? examples/langfuse keeps the trace there for debugging and seals the same model call here, linked by the Langfuse trace id.
Quick start (Python)
from sealedrun import PrivateKeySet, RunWriter, create_delegation, payload_ref, verify_run
principal = PrivateKeySet.generate()
agent = PrivateKeySet.generate()
delegation = create_delegation(principal, agent.public, agent_name="demo")
run = RunWriter(agent, delegation)
run.start()
run.append(
"llm_call",
target={"type": "model", "name": "gpt-4.1", "location": "cloud", "provider": "openai"},
payload=payload_ref("sha-256", b'{"messages": []}', b'{"choices": []}'),
data_labels=["pii"],
policy={"rule_id": "default", "decision": "allow", "reason": "no rule matched"},
)
run.end()
report = verify_run(run.records, {delegation["delegation_id"]: delegation})
print(report.complete, report.labels_sent_to_cloud)Layout
| Path | What | License |
|---|---|---|
SPEC.md | The SealedRun format specification | CC-BY-4.0 |
TRUST.md | What a bundle proves and what it does not (threat model) | CC-BY-4.0 |
spec/schema/ | JSON Schema for records, delegations and bundles | Apache-2.0 |
spec/vectors/ | Known-answer test vectors for independent implementations | Apache-2.0 |
spec/examples/ | Example records and an example bundle | Apache-2.0 |
packages/sealedrun-py/ | Reference Python implementation (sealedrun on PyPI) | Apache-2.0 |
packages/sealedrun-ts/ | TypeScript verification library (@sealedrun/core), used by the web UI | Apache-2.0 |
services/recorder/ | FastAPI service: bundle upload and verification API, hosts the UI (proxy arrives in stage 1) | Apache-2.0 |
apps/web/ | Next.js UI: bundle inspector and verifier | Apache-2.0 |
Install
pip install sealedrun # Python library: write, read and verify records and bundles
npm install @sealedrun/core # TypeScript verifier, runs in Node.js and in the browser
docker run --rm -p 127.0.0.1:8080:8080 ghcr.io/sealedrun/sealedrun:0.8.1 # recorder + InspectorReleases are published from GitHub Actions through PyPI and npm trusted publishing; both registries show the provenance attestation that links a package to the commit and workflow that built it.
Cryptography
- Canonicalization: JSON Canonicalization Scheme (RFC 8785).
- Hash chain: SHA-256 by default, SHA-384 allowed (
hash_alg). - Signatures: hybrid, every record carries both an Ed25519 and an ML-DSA-65 (FIPS 204) signature. Verifiers require both to be valid.
- Anchors: chain heads time-stamped by an RFC 3161 authority and published to a Sigstore Rekor transparency log; receipts are verified offline against a witness trust list (see Anchoring).
Run with Docker
docker compose up -d # SQLite in the sealedrun-data volume, UI and API on :8080
docker compose --profile postgres up -d # with PostgreSQL; set SEALEDRUN_DATABASE_URL, see .env.exampleOpen http://localhost:8080 , drop a bundle (for example spec/examples/bundle.zip) and the browser
verifies it. “Store in recorder” keeps it on the server.
Upgrading keeps the data: at start the recorder adds the columns a database written by an earlier release lacks (SQLite and PostgreSQL) and logs what it added. Going back to an older image on the same volume is not supported.
No authentication by default. The recorder listens on
127.0.0.1only and compose publishes the port to localhost. Before exposing it to a network, setSEALEDRUN_API_TOKENto a long random value and put TLS in front. Clients sendAuthorization: Bearer <token>; the Inspector asks for the token and keeps it for the current tab only./api/healthstays open.A bundle carries its own keys, so “it verifies” is not “it is genuine”. Get the signer’s
principal_idfrom the signer, not from the bundle, and give it to the verifier:verify_bundle(bundle, trusted_principals=[...])in Python,verifyBundle(bundle, { trustedPrincipals })in TypeScript, the “Trusted principal ids” box in the Inspector, orSEALEDRUN_TRUSTED_PRINCIPALS='["<principal_id>"]'for the recorder, which then refuses bundles from anyone else. Without it a report saysprincipal_trusted: false: integrity, not origin.The recorder answers only to the Host names in
SEALEDRUN_ALLOWED_HOSTS(default["127.0.0.1", "localhost"]; add your public name when you expose it; a proxy in front of it must forward the browser’s name inX-Forwarded-Host, which is held to the same list). Uploads are accepted from the Inspector’s own origin (Sec-Fetch-Site: same-origin; a sibling subdomain does not count), or from a script that presents the token; a request a foreign web page makes your browser send is refused. Errors the proxy returns to a client never name the upstream, its key variable or the failure class; that detail goes to the recorder’s log, and the access log drops query strings, so a?key=token never lands in a log line.
Record live traffic and export it
The recorder is also an LLM proxy. Point an agent’s SDK at it, and every model call becomes a signed record in a live run; export the run as a bundle at any time.
cp services/recorder/upstreams.example.yaml upstreams.yaml # keep the upstreams you use
export SEALEDRUN_API_TOKEN=change-me OPENAI_API_KEY=sk-...
SEALEDRUN_UPSTREAMS_FILE=./upstreams.yaml uv run sealedrun-recorderOPENAI_BASE_URL=http://127.0.0.1:8080/v1 OPENAI_API_KEY=change-me python agent.py # the token goes where the key went
curl -s -H "Authorization: Bearer change-me" http://127.0.0.1:8080/api/runs | jq '.[0].run_id'
curl -s -X POST -H "Authorization: Bearer change-me" \
"http://127.0.0.1:8080/api/runs/<run_id>/export?end=true" -o run.zip # end=true closes the run firstThe upstreams file is read once at start; edit it, then restart the recorder. A call the proxy
cannot route (no upstream for the model, or the model is reachable only through another wire
format) is answered 400/404 and leaves no record: nothing was sent to a model. In Docker, a
recorder that must reach Ollama on the same box needs host networking, because Ollama listens on
127.0.0.1 only: network_mode: host plus SEALEDRUN_HOST=127.0.0.1 (and drop ports:), or start
Ollama with OLLAMA_HOST=0.0.0.0 and point the upstream at host.docker.internal:11434 with
extra_hosts: ["host.docker.internal:host-gateway"]. A bind-mounted upstreams.yaml is read
from the mounted inode, so replace its content in place rather than swapping the file.
run.zip verifies with verify_bundle, verifyBundle or the Inspector like any bundle; the
recorder’s principal_id is at /api/identity. Anthropic, Ollama and Gemini SDKs use their own
base-URL setting (ANTHROPIC_BASE_URL, OLLAMA_HOST, Gemini http_options.base_url). Calls
that send the same X-SealedRun-Run header share a run until it has been idle for
SEALEDRUN_PROXY_RUN_IDLE_SECONDS (900), like the unlabelled run; the Inspector’s “Runs in the recorder”
tab lists live runs as they grow and has the Export buttons. Details in CHANGELOG.md.
Claude Code on a Pro/Max login needs no API key: mark the Anthropic upstream
client_auth: passthrough and send the recorder token in its own header, so the login token
reaches Anthropic and the recorder token stays behind the proxy.
export ANTHROPIC_BASE_URL=http://127.0.0.1:8080
export ANTHROPIC_CUSTOM_HEADERS="X-SealedRun-Token: $SEALEDRUN_API_TOKEN"
claudeCodex speaks the Responses API; give it a provider in ~/.codex/config.toml and the token in
SEALEDRUN_TOKEN:
model_provider = "sealedrun"
[model_providers.sealedrun]
name = "SealedRun"
base_url = "http://127.0.0.1:8080/v1"
env_key = "SEALEDRUN_TOKEN"
wire_api = "responses"Coding agents on Ollama send prompts of 10k tokens and more, while Ollama may run a model with a
smaller context and cut the prompt silently, tool definitions included. Raise it with
OLLAMA_CONTEXT_LENGTH or a model alias with PARAMETER num_ctx 16384.
Decision models
Decision models answer typed questions about a state (yes/no, a choice, a score) with
probabilities, and agents use them as gates before an action. The proxy records those calls
too, as llm_call records with operation decisions, so a gate’s verdict sits in the run next
to the action it allowed. Two wire formats are supported:
- OpenAI Decisions (
gpt-6-luna):POST /v1/decisions, routed through anopenaiupstream like chat;client.decisions.create(...)withOPENAI_BASE_URLpointed at the recorder. - SystemOne, the TypeSafe format spoken by OpenRouter (
typesafe/jev-*) and Microsoft Foundry (Decision-1):POST /api/alpha/decisions,/api/v1/systemoneor/providers/microsoft/v1/systemone, routed through asystemoneupstream and forwarded to<url>/systemone.
upstreams:
- name: openai
url: https://api.openai.com/v1
dialect: openai
key_env: OPENAI_API_KEY
location: cloud
models: ["gpt-*"]
- name: openrouter-decisions
url: https://openrouter.ai/api/v1
dialect: systemone
key_env: OPENROUTER_API_KEY
location: cloud
models: ["typesafe/*", "~typesafe/*"]
- name: foundry-decisions
url: https://YOUR-RESOURCE.services.ai.azure.com/providers/microsoft/v1
dialect: systemone
key_env: AZURE_FOUNDRY_API_KEY
auth: api-key
location: cloud
models: ["decision-*"]curl -s -X POST http://127.0.0.1:8080/api/v1/systemone -H "Authorization: Bearer change-me" \
-H "Content-Type: application/json" -d '{"model": "typesafe/jev-1.14",
"state": {"tool": "shell", "command": "rm -rf /var/lib/postgres", "host": "prod-db-1"},
"questions": {"touches_prod": {"type": "noul", "instructions": "Touches production data?",
"criteria": {"true": "destroys or alters production data", "false": "otherwise"}}}}'The record keeps the request and reply as payloads, the model snapshot and token counts in
sealedrun.llm, and dialect: systemone in sealedrun.proxy. The answers themselves are
extracted into sealedrun.decision (SPEC 10.7), one normalised entry per question, so the
verdict stays in the record when a run is exported without payload bodies. The policy rule
applies as to any cloud call. Decision calls never stream.
The reply carries the record id in X-SealedRun-Record. Hand it to record_gate and the run
shows what your code did with the verdict: a policy_decision step linked to the decision
call by parent_record_id, with your thresholds and the outcome (approve, block,
review), followed by the tool call that ran or did not.
import httpx
from sealedrun import Recorder
recorder = Recorder("http://127.0.0.1:8080", token="change-me", run="deploy-42")
reply = httpx.post(
"http://127.0.0.1:8080/api/v1/systemone",
headers={"Authorization": "Bearer change-me", "X-SealedRun-Run": "deploy-42"},
json={"model": "typesafe/jev-1.14", "state": state, "questions": questions},
)
touches_prod = reply.json()["answers"]["touches_prod"]["noul"]
verdict = "block" if touches_prod > 0.5 else "approve"
recorder.record_gate(
reply.headers["X-SealedRun-Record"],
verdict,
thresholds={"touches_prod": 0.5},
reason=f"touches_prod {touches_prod:.2f}",
)const verdict = touchesProd > 0.5 ? "block" : "approve";
await recorder.recordGate(reply.headers.get("x-sealedrun-record")!, verdict, {
thresholds: { touches_prod: 0.5 },
});MCP servers
MCP servers that speak Streamable HTTP go behind the same recorder, listed under mcp_servers in
the upstreams file and reached at /mcp/<name>:
mcp_servers:
- name: github
url: https://api.githubcopilot.com/mcp/
key_env: GITHUB_MCP_TOKEN # sent as a bearer token unless auth says otherwise
location: cloudtools/call, resources/read, prompts/get and tools/list become tool_call records in the
same run as the model calls when they carry the same X-SealedRun-Run label (or none).
tools/list is recorded once per run and server until its result changes. Everything else passes
through unrecorded: notifications, initialize, server/discover, subscriptions/listen, GET
streams, DELETE, and requests the server refused. Both protocol eras pass through unchanged: the
stateless 2026-07-28 one and the session-based 2025-03-26 to 2025-11-25 one. Three shapes are
refused with 400 and never forwarded, because they could slip a call past the record and the
policy rule: a JSON-RPC batch, a request whose id is not a string or an integer, and a recorded
method sent without an id. Errors the proxy itself generates are JSON-RPC error objects with
the application code 40000 (40003 when the policy rule refused the call).
claude mcp add --transport http github http://127.0.0.1:8080/mcp/github \
--header "X-SealedRun-Token: $SEALEDRUN_API_TOKEN" --header "X-SealedRun-Run: my-task"# ~/.codex/config.toml
[mcp_servers.github]
url = "http://127.0.0.1:8080/mcp/github"
bearer_token_env_var = "SEALEDRUN_TOKEN"
http_headers = { "X-SealedRun-Run" = "my-task" }
default_tools_approval_mode = "approve" # codex exec cannot ask; interactive Codex canMCP servers over stdio
A server the client launches as a subprocess is wrapped instead of proxied. sealedrun-mcp-wrap
comes with pip install sealedrun, starts the real server, relays its stdin and stdout unchanged
and posts each tools/call, resources/read, prompts/get and tools/list to the recorder as a
tool_call record (transport: stdio) before the reply reaches the client. The rules are the
ones of the HTTP proxy; the server’s stderr and exit code pass through. The recorder address and
token come from --url / SEALEDRUN_URL and SEALEDRUN_TOKEN or --token-file (never a
command-line token: /proc shows it to every local user); --run (or
SEALEDRUN_RUN) is the same label as X-SealedRun-Run, so the tool calls land in the run of the
model calls; --agent <name>/<version> (or SEALEDRUN_AGENT) is the same value as
X-SealedRun-Agent. An MCP client starts its servers before the first model call, so a run often
opens on a wrapped tools/list, and without --agent that run does not name its agent. A recorder that cannot be reached does not stop the call: the reply is delivered and
a warning goes to stderr; with --strict the client gets a JSON-RPC error instead. The wrapper
keeps at most 1024 open requests (older ones are recorded as truncated), relays a line above 16 MB
in pieces without recording it (warning), and gives a recorder post up to --timeout seconds in
total before it gives up. POSIX only.
claude mcp add --env SEALEDRUN_TOKEN=$SEALEDRUN_API_TOKEN --transport stdio fs -- \
sealedrun-mcp-wrap --server fs --run my-task --agent claude-code/2.1.284 -- npx -y @modelcontextprotocol/server-filesystem .# ~/.codex/config.toml
[mcp_servers.fs]
command = "sealedrun-mcp-wrap"
args = ["--server", "fs", "--run", "my-task", "--agent", "codex/0.160.1", "--", "npx", "-y", "@modelcontextprotocol/server-filesystem", "."]
env = { SEALEDRUN_TOKEN = "..." }Any integration can post its own steps the same way: POST /api/steps (bearer token, optional
X-SealedRun-Run) or POST /api/runs/<id>/steps takes a JSON body with kind, target,
actor, outcome, policy, data_labels, extensions and the payload bodies as request /
response text or request_base64 / response_base64, validates the whole record against the
schema before sealing it and returns the signed record; the recorder sets occurred_at. Every
such record carries extensions["sealedrun.step"] = {"source": "self_reported"} (SPEC 10.5): it is
the caller’s own account, and the Inspector tags it “self-reported”. A step cannot carry the
extensions only the recorder writes (sealedrun.proxy, sealedrun.otel, sealedrun.anchor,
sealedrun.delegation, …) and its label sources are limited to manual and header; run
summaries count the labels of self-reported cloud steps in labels_self_reported, apart from
labels_sent_to_cloud, which only counts what the recorder itself saw leave.
A2A agents
Agents that speak A2A 1.0 go behind the recorder too, listed under a2a_agents in the
upstreams file (same fields as mcp_servers; url is the agent’s JSON-RPC endpoint from its
card) and reached at /a2a/<name>. The proxy serves /a2a/<name>/.well-known/agent-card.json
with the interface URLs rewritten to itself, so an SDK client that discovers the agent there
keeps talking through the recorder; a signed card no longer matches its signature after the
rewrite, so verify the original directly when that matters. SendMessage, SendStreamingMessage
and CancelTask (and the REST message:send, message:stream, tasks/{id}:cancel) become
tool_call records with sealedrun.a2a (task, context and message ids, last task state); the
outcome follows the task state (COMPLETED success, FAILED / REJECTED / CANCELED error,
INPUT_REQUIRED and other open states pending). The A2A 0.3 names message/send,
message/stream and tasks/cancel are recorded the same way. To a local agent, GetTask,
ListTasks, SubscribeToTask and push-notification configs pass through unrecorded. To a
cloud agent every POST, PUT and PATCH is recorded and governed by the policy rule, whatever its
method or sub-path (the record names it, for example GetTask or PUT /tasks/t-1/ pushNotificationConfigs/c-1), so no body can leave for a cloud agent unseen; a cloud request the
proxy cannot read (not a JSON object, a batch, an id that is not a string or an integer) is
refused with 400. A sub-path with . or .. segments or percent-encoded delimiters is refused
for every agent. Proxy-generated JSON-RPC errors carry the application code 40000.
a2a_agents:
- name: planner
url: http://127.0.0.1:9999/
location: localcard = await A2ACardResolver(http, "http://127.0.0.1:8080/a2a/planner").get_agent_card()Report steps from your own code
Actions an agent takes inside its own process (SQL, files, shell, HTTP) never pass a proxy.
Report them with the SDKs; the recorder validates and signs each step onto the run named by the
label, next to the proxied model calls. Python (pip install sealedrun, standard library only):
from sealedrun import Recorder
recorder = Recorder("http://127.0.0.1:8080", token="...", run="my-task")
with recorder.step("tool_call", "db.query", provider="postgres") as step:
step.request = sql
step.response = rows # anything JSON-serialisable, or text/bytes
@recorder.tool("fetch_page", location="cloud")
def fetch_page(url: str) -> str: ... # arguments and return value are recordedTypeScript (npm install @sealedrun/core, Node 22):
import { Recorder } from "@sealedrun/core";
const recorder = new Recorder("http://127.0.0.1:8080", { token: "...", run: "my-task" });
await recorder.step(
"tool_call",
"db.query",
async (step) => {
step.request = sql;
step.response = await db.query(sql);
},
{ provider: "postgres" },
);
const fetchPage = recorder.wrap(fetchPageImpl, "fetch_page", { location: "cloud" });When a step changes a system outside the recorder’s view, attach what that system returned, so the auditor can check the record against its log (SPEC 10.9):
from sealedrun import receipt_for
recorder.record(
"tool_call",
"git/commit",
provider="git",
response=output,
receipt=receipt_for("git_commit", ref=sha, issuer="github.com/acme/repo"),
)import { receiptFor } from "@sealedrun/core";
await recorder.record("tool_call", "out.txt", {
provider: "fs",
receipt: receiptFor("file_hash", { ref: "/data/out.txt", data: bytesAfterWrite }),
});A step whose body throws is recorded with outcome error and the error is rethrown. A recorder
that refuses a step (schema) or cannot be reached raises RecorderError; unlike the MCP wrapper
there is no fail-open, because the caller asked for the record.
OpenTelemetry spans
Frameworks that already emit OpenTelemetry GenAI spans (LangChain and LangGraph through
opentelemetry-instrumentation-langchain, the OpenAI Agents SDK, LangSmith’s OTel export) can
send them straight to the recorder: point an OTLP/HTTP exporter at POST /otlp/v1/traces
(binary protobuf or JSON, gzip accepted) with the recorder token in a header.
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:8080/otlp/v1/traces
export OTEL_EXPORTER_OTLP_HEADERS="X-SealedRun-Token=$SEALEDRUN_API_TOKEN,X-SealedRun-Run=my-task"execute_tool spans become tool_call records (tool name, gen_ai.tool.call.arguments and
gen_ai.tool.call.result as payloads), model spans (chat, embeddings, …) become llm_call
records with sealedrun.llm from the gen_ai.* attributes, memory operations memory_read /
memory_write; other spans are dropped. The spans of one trace land in one run: the
X-SealedRun-Run header, else the trace id (gen_ai.conversation.id is kept on the record but
cannot group, because batch exporters send child spans before the agent span that carries it).
Ollama, llama.cpp and vLLM providers are local, the OTel-listed cloud providers cloud,
anything else unknown. A span with status
ERROR gives outcome error; a span whose record would not verify is counted in the OTLP
partial_success reply and skipped; a re-sent span is recorded once. SPEC 11 has the mapping.
OpenRouter Broadcast
OpenRouter can send a trace of every call made with an API key to an OpenTelemetry collector (Settings > Observability > Broadcast, destination “OpenTelemetry Collector”). A recorder is such a collector, so calls that go to OpenRouter directly, without the proxy, still land in a sealed run. Set:
- Endpoint:
https://<your recorder>/otlp/v1/traces(the recorder must be reachable from the internet over HTTPS, and itsallowed_hostsmust list that host name) - Headers:
{"X-SealedRun-Token": "<SEALEDRUN_API_TOKEN>"}
“Test Connection” posts an empty export and expects a 2xx. After that each OpenRouter call
becomes one run otel:<trace id> with one llm_call record: model, upstream provider, token
usage and finish reason in sealedrun.llm, the prompt and completion as payloads (OpenRouter
puts them in gen_ai.prompt / gen_ai.completion; Privacy Mode leaves them out). A decision
call (/api/v1/systemone, /api/alpha/decisions) arrives with operation decisions; the
recorder reads the answers out of the completion into sealedrun.decision, the same extension
the proxy writes, so a gate’s verdict is on the record either way. OpenRouter’s own routing
span (“provider attempt”) carries no GenAI operation and is not recorded. Runs made this way
have no X-SealedRun-Run grouping and no sealedrun.gate record unless your code reports one
through the SDK with the record id from the Inspector.
Servers that log users in with OAuth cannot do that through the proxy: their tokens are bound to
the server’s own URL, and clients refuse the discovery answer for a different address. Give such
a server a static token (key_env, for example a GitHub personal access token), or mark it
client_auth: passthrough and let the client send its own Authorization header next to
X-SealedRun-Token. Otherwise connect the client to the server directly; those calls are not
recorded.
Anchoring
A run’s own keys prove that nothing changed after signing. They cannot prove when a record
existed, or that the operator did not rewrite the whole run and sign it again. Anchoring closes
that gap: every ten minutes, and again on export, the recorder sends the current chain head to a
witness the operator does not control and stores the witness’s receipt as a signed anchor
record in the chain (SPEC 8). Two witness types are supported and can run together:
rfc3161: a time-stamp authority signs the head with the current time. The token is verifiable offline against the authority’s root certificate and carries the time.rekor: the head is published to a Sigstore Rekor v1 transparency log, an append-only public log; the receipt holds the entry, its inclusion proof and the log’s signed checkpoint. The recorder signs the head with a P-256 anchoring key it keeps next to its other keys (keys/anchor.pem, public part inGET /api/identity); that key carries no identity claim, the chain does.
Anchoring is on by default with the time-stamp authority. SEALEDRUN_ANCHORS names the
anchors in the order tried; [] turns anchoring off:
SEALEDRUN_ANCHORS='["tsa","rekor"]' # default ["tsa"]
SEALEDRUN_ANCHOR_TSA_URL=https://timestamp.sigstore.dev/api/v1/timestamp # default; free, run by Sigstore
SEALEDRUN_ANCHOR_TSA_FALLBACK_URL=http://timestamp.digicert.com # default; tried when the first fails
SEALEDRUN_ANCHOR_REKOR_URL=https://rekor.sigstore.dev # default; public transparency log
SEALEDRUN_ANCHOR_INTERVAL_SECONDS=600 # default| Anchor | Who runs it | What it proves | Needs |
|---|---|---|---|
tsa | Sigstore (or any TSA) | the head existed no later than the signed time | nothing |
rekor | Sigstore public log | the head is in an append-only public log | the recorder’s P-256 key |
The recorder keeps no anchor of its own: an anchor is only worth something when the party
holding it is not the one that could rewrite the run. GET /api/identity reports anchors
(configured, last success and last failure per anchor), and the Inspector shows it.
What an anchor does not prove: a time-stamp says the head existed no later than the signed time, not that it is the only head the operator ever had; a log entry without independent witnesses could in theory be shown differently to different readers (a split view). Two independent anchors over the same head, a time-stamp and a public log, are the mitigation this release offers; a relying party that needs more re-queries the witness itself.
A witness that is down never blocks a run: the recorder retries once, tries the fallback, then
logs the failure and carries on; the next cycle anchors the head that moved. Run summaries show
last_anchor_at. Note that the log is public: the chain head (a hash) and the anchoring public
key become visible to anyone, nothing else does.
Verifiers check the receipts offline. verify_run / verify_bundle (Python) and
verifyRunAsync / verifyBundleAsync (TypeScript, the synchronous functions skip this step)
report anchors (receipts bound to the chain) and anchors_witness_verified (receipts that
verified against a witness in the trust list); the Inspector shows the witness, the witness
time and the verdict per anchor. A verified anchor means: this chain head existed no later than
the witness time. python -m sealedrun run.zip prints the same summary. A relying party that
wants the strongest statement also re-queries the witness: for Rekor, search the public log by
the head hash (POST https://rekor.sigstore.dev/api/v1/index/retrieve {"hash":"sha256:<head>"}
or the Inspector’s link to the entry); for a time-stamp, the token itself is the statement.
The trust list ships in both packages as witnesses.json (Sigstore’s time-stamp authority and
Rekor log key from Sigstore’s trusted_root.json, DigiCert Trusted Root G4). Pass your own with
witnesses= / witnesses: to add a witness or to trust nobody ([]), and strict_witness /
strictWitness to fail verification on an anchor that does not verify. To refresh the shipped
list, take the timestampAuthorities[].certChain root and validFor, and the tlogs[] entry
for rekor.sigstore.dev (publicKey.rawBytes as PEM, logId.keyId as hex), from
https://raw.githubusercontent.com/sigstore/root-signing/main/targets/trusted_root.json, and
DigiCert’s root from https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem; never take a
certificate from a receipt. Regulated deployments that need a qualified time-stamp under eIDAS
point SEALEDRUN_ANCHOR_TSA_URL at their qualified trust service provider and add its root to
the trust list; the EU AI Act’s logging duty (Art. 12) does not itself require one.
Data labels and the first policy rule
A record carries data_labels (SPEC 5.6: pii, nda, secret, acme:tier-1, …) so that a
reader can see which kind of data each step touched and which of it left the host. Through the
proxies the caller states the labels itself, in a request header on every channel the recorder
records (LLM, MCP HTTP, A2A, and POST /api/steps when the step body names none):
X-SealedRun-Labels: nda, piiThe header is checked (lowercase labels, at most 64; a bad one is a 400 and nothing is
forwarded), never passed on to an upstream, and lands in the record as data_labels with
sealedrun.labels[label].source = "header", so a label the caller asserted is never mistaken
for one a classifier found. Run summaries count labels_sent_to_cloud per label.
On top of that sits one policy rule, off by default:
SEALEDRUN_POLICY_BLOCK_TO_CLOUD=["nda","secret"] # labels that must not reach a cloud target
SEALEDRUN_SCOPE={"allowed_locations":["local"]} # what the delegation allows; verifiers report the restA call that carries one of those labels to an upstream, MCP server or A2A agent whose
location is cloud is refused before any byte reaches it: the LLM proxy answers 403 in the
SDK’s own error shape, the MCP proxy a JSON-RPC error 40003, the A2A proxy the same or a
PERMISSION_DENIED status on the REST binding, each naming the rule. The chain keeps the
evidence: a record with outcome: blocked, policy: {rule_id: "recorder/no-nda-to-cloud", decision: block, reason: "target.location=cloud and labels contain nda"} and the digest of the
request that was not sent (SPEC 5.7). A labelled cloud call the rule lets through carries
policy: {rule_id: "default/allow", decision: allow, ...}, so the reader sees that the rule was
consulted; local targets and unconfigured recorders write no policy object. The Inspector counts
the blocked steps and shows the rule and reason on each. The rule covers tools/call,
resources/read, prompts/get, and every POST, PUT or PATCH to a cloud A2A agent; a configured
label outside the SPEC 5.6 pattern stops the recorder at start. It cannot cover the
stdio wrapper (a stdio server has no known location) or self-reported steps (the caller states
its own outcome), and it decides on stated labels only. Classifiers that find labels in the
payload, redirecting a call to a local model and approval flows are deliberately not here yet.
Development
uv sync --all-packages
pnpm install
uv run pytest && pnpm test
pnpm --filter @sealedrun/core build && pnpm --filter @sealedrun/web build # static UI into apps/web/out
uv run sealedrun-recorder # http://localhost:8080See CONTRIBUTING.md .
Standards
Built on, not instead of: RFC 8785 (JCS), FIPS 204 (ML-DSA), RFC 8032 (Ed25519), MCP SEP-3004 and IETF draft-sharif-agent-audit-trail (record model and field names), OpenTelemetry GenAI semantic conventions (export mapping), EU AI Act Articles 12, 19 and 26 (logging and retention).
Compliance: docs/aiuc-1.md lists which part of a bundle is evidence for which AIUC-1 control, and how to raise alerts, feed a SIEM and upload to Vanta with the tools you run.
License
Code and schemas: Apache-2.0. Specification text (SPEC.md, TRUST.md): CC-BY-4.0.
Contributions require the CLA .
Security
Report vulnerabilities privately, see SECURITY.md . Please do not open public issues for them.