Quickstart
Record your first run in 60 seconds
Install the SDK, wrap your agent, and verify the result yourself. No account is required to use the open-source software. Python and TypeScript are byte-for-byte compatible.
Last updated
Install
The CLI (pr quickstart, verifier, and recording server). Recommended: install with
uv, which brings its own pinned Python, so a later
brew upgrade or system Python change cannot orphan it:
uv tool install provenrail
No uv yet? One line installs it: curl -LsSf https://astral.sh/uv/install.sh | sh.
Prefer pip? It works, but install it inside a virtualenv, not a system or Homebrew Python.
A global pip install breaks the day Homebrew upgrades Python (the package lands in the
old interpreter's site-packages). If that happens, recover with
pipx reinstall provenrail or switch to the uv command above:
python3 -m venv .venv && source .venv/bin/activate
pip install provenrail
Using the SDK from your own project? Add it to that project's environment, again not system Python:
uv add provenrail # or: pip install provenrail (venv active)
TypeScript / Node 20+ (recording SDK):
npm install provenrail
Version 0.6.0. The supervisor commands below (pr undo,
pr stop, pr resume, pr remote setup, pr away,
pr back, pr scan and pr brief) need 0.6.0. The package on PyPI
is older and does not have them, so install from GitHub:
uv tool install git+https://github.com/pofky/provenrail
The Claude Code plugin needs none of that. It carries its own engine, and its commands are
/guard-undo, /guard-stop, /guard-scan,
/guard-status, /guard-card and /guard-budget. For the other
hosts, pr guard install --host codex|gemini|copilot|cursor wires the same hook. Only the
Claude Code adapter is driven with a payload captured from the running CLI; the other four are
written from each vendor's published hook contract and are not yet driven against a live install.
Undo (pr undo)
Before every action that can change a file, the hook checkpoints the working tree into a shadow git
store under ~/.provenrail/projects. Your project's real .git is never opened
or written. Because it is a snapshot of the whole tree and not of the file a tool names, it also
covers what a shell command changed, which Claude Code's own checkpointing does not, according to its documentation.
pr undo # list checkpoints, with what each action changed
pr undo last # put the tree back to before the most recent action
pr undo 12 # put the tree back to checkpoint #12
pr undo 12 --diff # show what that would change, and change nothing
pr undo 12 src/app.py # restore only these paths
pr undo init # build the store now, so snapshots in the hook are incremental
A full restore is verified by tree hash and says so: "Verified: the working tree now matches the
checkpoint exactly." Every restore first takes a checkpoint of the state it replaces, and prints the
command that puts it back. Checkpoints are kept for 14 days. Nested repositories and submodules are recorded as a pointer and are never touched by a restore. If a snapshot cannot finish in 10 seconds, checkpoints pause for that project and Provenrail says so; the call still runs. A restore that had to leave
something alone, a nested repository or a directory holding files that are in no checkpoint, says so and is
not marked verified. Inside Claude Code it is /guard-undo. If work is already gone, try these recovery steps first.
# .provenrail.json
{"undo": {"enabled": true, "include": [".env", ".env.*"]}}
A feature switched off in .provenrail.json (undo, watch, lanes) is announced once a day,
because that file is in the working tree, where an agent can write it.
include lists gitignored files to checkpoint anyway, as globs relative to the repository
root. The default is the root-level .env files, because an .env is the one
ignored file that is neither regenerable nor backed up anywhere.
Measured on a 381-file repository: 1.04 s for the first snapshot and 0.03 s for each one after it. The supervisor adds about 8 ms to a hook call that takes no snapshot (78 ms against 70 ms before, Python 3.9).
.env files. A file over 50 MiB is skipped and listed. Nested
repositories and submodules are recorded as a pointer, not by content, and a restore never touches them. Commits, branches and stashes
in your real repository are not rewound, and neither are git history, databases and anything outside
the repository. It works inside git repositories only.Stop and resume (pr stop)
pr stop "wrong branch" # refuse every tool call from every agent on this machine
pr resume # lift it
While the stop file exists, every tool call in every project and on every host is refused, with the
reason you gave. It is deliberately machine-wide: the person reaching for it is not in a position to
remember which of four terminals is the runaway. It reaches agents that run the Provenrail hook, at each
one's next tool call. A call already running is not interrupted. It is checked first, with a single file check, and nothing
that fails afterwards can lift it. The phone remote can stop and resume too. Inside Claude Code it is
/guard-stop.
Loop breaker
Puts the next call to a human after the same call, or the same short cycle of one to three calls, has repeated 8 times back to back. It asks rather than blocks, because polling a build is also a run of identical calls and only a person can tell the two apart. Cycles longer than three calls are not looked for.
# .provenrail.json
{"watch": {"enabled": true, "repeats": 8}}
The threshold is measured, not guessed. Across 3,186 real Claude Code sessions and 147,356 tool calls on the author's machine, 97.1% of sessions never repeated a block at all and 0.47% would have been questioned.
File lanes
When two agent sessions work in one checkout, the second is asked before it edits a file the first wrote in the last 15 minutes, with the other session named. It asks rather than blocks, because the second edit is often exactly what you want.
# .provenrail.json
{"lanes": {"enabled": true, "minutes": 15}}
Limits. A lane is not a lock. It sees edits made by file-editing tools, not by shell commands: a shell command that rewrites a file takes no lane, because a shell command does not say which files it will touch.
Phone remote
Approve or refuse a risky call, and stop or resume every agent, from Telegram (your own bot) or ntfy (a secret topic). No Provenrail server is involved: messages go from your machine to the channel you chose.
pr remote setup telegram # asks for the bot token without echoing it; --token still works
pr remote setup ntfy # no account, a secret topic
pr remote test # send a question you can answer
pr remote status # what is set up, and where questions go
pr remote off # remove it from this machine
pr away # send questions to the phone
pr back # ask questions at this machine again
The Telegram bot is one you create with @BotFather. The settings live at the user level, not in the
project, and are set with pr remote config:
pr remote config --ask auto --detail full --wait-s 300
--askisauto,alwaysornever. Withauto, questions go to the phone when you are away (pr away, or automatically after 2 minutes idle on macOS) and stay on screen when you are at the keyboard.--detailisfullorshape. By default up to 600 characters of the command are sent; withshapeonly the verb and flags are. A command longer than 600 characters is cut, with a line saying how many characters are not shown.--wait-sis how long the hook waits for an answer. It never waits longer than the timeout the host gave the hook.
The project's .provenrail.json sits in the working tree, where an agent can write it, so it
has less say than you do. It can no longer set remote.ask, cannot set
remote.detail to "full", and cannot lengthen the wait. It may only make the remote
more private or quicker to give up:
# .provenrail.json
{"remote": {"detail": "shape", "wait_s": 120}}
On hosts with no approval prompt of their own (Codex, Gemini CLI), a rule that needs a human becomes a real question on the phone instead of a flat refusal (by design; those two adapters are not yet driven against a live install).
Limits. It never fails open: no answer, no network, an unpaired chat or an ended evaluation leaves the verdict as it was. Telegram answers are accepted only from the one chat paired by typing a code your machine printed. For ntfy the topic name is the secret, so anyone who knows it can answer; it is 128 random bits and is not printed after setup. On ntfy, anyone who knows the topic name can answer, and so can a program running in your own account; Telegram is the stronger channel. The remote guards against a trusted agent's mistakes while you are away, not against hostile software running as you. The text of a question passes through the service you chose, under its terms; see privacy. The remote runs as a 14-day evaluation that needs no account, then needs the paid plan. The licence check is a commercial control in open-source code, not DRM.
The 14 days are counted on your machine from the day you run pr remote setup. When they end,
questions stay on the machine exactly as they did before you set the remote up, and nothing else changes.
No card or account was taken, so nothing is charged; the remote starts again only if you buy the plan and
run pr activate.
Scan (pr scan)
pr scan # check the current folder
pr scan path/to/repo # check a folder you just cloned
pr scan --json # machine-readable
pr scan --strict # exit 1 on medium findings as well as high
Answers one question about a repository you have just cloned: which files will run a command, start
a process, change the agent's permissions or rewrite its instructions without you asking. It screens
agent settings, hooks, MCP servers, skills and commands, folder-open editor tasks, devcontainer
lifecycle commands, install-time scripts, git submodules, and instruction files for invisible Unicode,
hidden HTML comments and download-piped-into-a-shell patterns. It is static and offline: it never
executes or imports anything from the scanned tree, makes no network call, and never follows a symlink
out of the directory. Inside Claude Code it is /guard-scan.
Limits. It screens known auto-run surfaces and does not read or judge the repository's code. A hook that calls a script is reported as a hook, not as whatever the script does. "Nothing found" means none of the places it knows about runs anything on its own, not that the repository is safe. Hitting its file-size or file-count cap is reported as a finding rather than skipped quietly, and config it cannot parse is a finding, never a pass.
Brief (pr brief)
pr brief # the most recent session in this repository
pr brief --session 3fa9 # a session, by id prefix
pr brief --since 12 # start at checkpoint #12
pr brief --all # every session in the log
pr brief --out brief.txt # write it to a file; --json for machine-readable
A review brief of what the agent changed, for the person who has to review it. It is built from the recorded checkpoints and the guard journal, not from the agent's own summary: the net change, files touched and reverted, shell commands that changed files, sensitive paths (lockfiles, migrations, auth, CI, agent config), and what was stopped or questioned.
Limits. It states facts and names what to check. It does not accuse, and it does not claim that tests pass. It needs checkpoints, so it works inside git repositories only.
Guard a coding agent (no code)
If you run Claude Code, this is the shortest path to something useful, and it needs nothing installed. Inside Claude Code:
/plugin marketplace add pofky/provenrail
/plugin install provenrail-guard@provenrail
That is the whole setup. The plugin carries its own dependency-free engine, so 45 rules are
armed on the next tool call with no package, no account and no config file.
/guard-status shows what it has actually stopped, /guard-card prints
that history in a form safe to paste anywhere, and /guard-rules
lists every rule. The full guide covers what each pack
blocks and why some rules ask instead of deny.
To wire the hooks yourself instead, or to record as well as block:
pr quickstart # local recording server, no account, nothing leaves your machine
pr guard install # arms destructive + secrets + production + access, installs the hooks
From your next Claude Code session in that folder:
rm -rf,terraform destroy,git push --force,DROP TABLE,chmod 777and leaked API keys are blocked before they run, and the agent is told which rule fired.- An action that needs a human (touching
.env, a deploy, a migration) becomes a permission prompt instead of a hard block, and your approval is recorded as human oversight. - Every decision is Ed25519-signed and hash-chained, so the block itself is evidence.
pr guard status # what is armed, and what it blocked
pr guard receipt # export the proof, then pr verify it yourself
pr guard uninstall # removes only our hooks; your own are untouched
The verdict is computed offline, before anything touches the network, so a recording server that is down can never turn a deny into an allow. A decision that cannot be recorded is journalled locally and reported as unsigned and pending, never as proof.
Honest scope. This covers the tool calls Claude Code routes through its
hooks. It cannot constrain a process that never calls them. Per-session limit
rules carry their counts in a local state file so a blast-radius cap holds across hook
processes; that file is editable, so it is a convenience rather than evidence, and
deny and require_oversight never read it. Clear it with
pr guard reset. Other agent hosts are not claimed until their hook contract has
been read and tested.
What your agents already did (pr report)
The fastest useful thing this tool does, and it needs nothing configured. Claude Code writes
a JSONL transcript for every session, one directory per project under
~/.claude/projects. pr report streams those files, prices every
model call, counts every tool call, and replays each bash command through the shipped
guardrail rules to show what they would have stopped. It is retroactive: it answers before
you have changed anything about how you work.
uv tool install provenrail
pr report
No account, no config file, and no network call anywhere in the module or in what it reaches; the test suite asserts it by breaking the socket. Files are read line by line and most lines are rejected on a substring test before a JSON parser sees them, so a multi-gigabyte corpus never lands in memory. On the machine these docs were written on, 2,163 transcripts and 3.79 GB took 128 seconds.
Flags
| Flag | What it does |
|---|---|
PATH | A transcript directory to read instead of
~/.claude/projects. Positional and optional. |
--since YYYY-MM-DD | Only transcripts written on or after this date. A transcript is append-only, so a file last written before the cutoff can hold no line after it and is skipped whole on its mtime. The count of files skipped this way is printed, so a narrow window is visibly a narrow window. |
--project NAME | Only projects whose directory name contains
NAME. Substring, case-sensitive. |
--json | The machine-readable document instead of the text
report. Its shape is versioned by the schema field; pin on that. |
--share | The version that is safe to post. See below for exactly what it removes. |
What --share removes
Three things, and only these three:
- The root path is not printed, so your home directory and your username do not appear.
- Every project name is replaced by
project-plus a short hash of it. The same project keeps the same hash within one report, so the rows still line up. - Every bash command is reduced to its verb and flags, and every operand is dropped, because an operand is where a path, a hostname and an API key all live.
It does not redact anything else, because there is nothing else: the report never carries prompt text, file contents, file names or environment values in either mode. Rule names, tool names, model names, counts and dollar figures are printed in both.
What each number means
- estimated total: the sum over every model call found, priced from the model and token usage the transcript itself records. Estimated at API list price, and notional on Pro and Max flat-rate plans, where there is no per-token charge at all.
- model calls priced and unpriced calls: a model with no verified rate is counted as unpriced, never as free, so a total is never silently short.
- repeat lines folded into their call: one model call can appear on many transcript lines, as streamed partials and as history copied into a forked session. Calls are deduplicated by message id across the whole corpus and the largest usage seen for an id wins, so a forked session is not billed twice.
- Claude Code recorded its own total: where the host wrote its own running
cost on a
cost-stateline, both figures are printed side by side over exactly the sessions that have both. Neither is reconciled into the other: adopting the host's number would hide a disagreement, and dropping it would hide that a second opinion existed. Both sides are whole-session figures, including any part of a session before--since. - tool calls, bash commands, files written or edited: counted from the tool-use records. Files are distinct paths, not writes.
- sessions that ended mid tool call: a tool call with no result after it, which is what a crashed or killed session looks like from the outside.
- would have been refused and would have been sent to you:
each command replayed offline through the same rules the guard ships, counted as
denyandrequire_oversight. This is a replay, not history: it says what would happen if the guard were installed, not what did happen. - blast radius: the same stopped calls grouped by which rule pack they hit, so the answer is a category rather than a rule name.
- unreadable transcripts and lines: a file or line that could not be parsed is counted as unread, never as empty.
The --json shape
Stable and versioned by schema. A change of shape changes that string.
{"schema": "provenrail.report/1", "root": str|null, "since": str|null, "share": bool,
"read": {"transcripts", "unreadable_transcripts", "unreadable_lines", "bytes",
"skipped_by_since", "seconds"},
"cost": {"estimated_usd", "caveat", "model_calls", "unpriced_calls", "repeat_lines_folded",
"host_crosscheck": {"sessions", "host_recorded_usd", "estimated_here_usd"}|null,
"by_model": [{"model", "calls", "estimated_usd", "priced"}]},
"activity": {"sessions", "first", "last", "tool_calls", "bash_commands",
"files_written", "sessions_ended_mid_tool_call", "by_tool": [[name, n]]},
"risk": {"screened", "deny", "ask", "by_rule": [{"rule", "verdict", "count"}],
"by_shape": [{"shape", "verdict", "count"}]},
"blast_radius": {"by_pack": [{"pack", "title", "count"}],
"busiest_session": {"project", "files", "tool_calls"}|null},
"projects": [{"name", "sessions", "estimated_usd", "tool_calls", "deny", "ask",
"first", "last"}]}
The caveat field carries the same sentence the text report prints, so a consumer
that shows estimated_usd has no excuse for showing it bare.
pr report reads Claude Code
transcripts. No other agent host is read, and none is claimed until its format has a fixture
in this repository. The dollar figure is a floor: measured over the 49 sessions where Claude
Code had recorded its own total, the estimate came out about 12 per cent below it. Two known
causes are not modelled, because neither is visible in a transcript. Fast mode bills Claude
Opus 5 at $10/$50 per million tokens against the standard $5/$25, and web search bills $10
per 1,000 searches. No multiplier is applied to close the gap, so the number misses low
rather than high. The long context window is not one of the causes: Claude 4.6 and later
include the full 1M token context at standard per-token rates.Python quickstart
One command sets up a local recording server (the "sink" in the API docs) and writes .provenrail.json, so your code carries no URLs or tokens:
pr quickstart # starts the local recording server + writes config
Then two lines in your code:
import provenrail as fr
with fr.record("my-agent"):
... # your agent runs; model and tool calls are captured
fr.record(...) provisions a stream, opens a signed session, and seals and drains it
off-box when the block exits. A decorator form exists too: @fr.recorded("nightly-job").
Stop it with pr quickstart --stop; point at a recording server you host with
pr quickstart --url <URL>.
Run your agent as many times as you like: each run becomes its own sealed session on the
same stream, and one export verifies them all. The first run also creates
.provenrail.key, the device signing key reused by every later run. Keep it out
of version control (add it to .gitignore next to .provenrail.json);
losing it only means future runs sign under a new identity.
Drop-in capture (one line per SDK)
from provenrail.integrations import instrument_openai, instrument_anthropic, instrument_mcp
instrument_openai(openai_client, fr) # every model call captured
instrument_anthropic(anthropic_client, fr)
instrument_mcp(mcp_session, fr) # every MCP call_tool captured
LangChain / LangGraph (compliance callback handler)
from provenrail.integrations.langchain import ComplianceCallbackHandler
with fr.record("my-agent") as rec:
chain.invoke(x, config={"callbacks": [ComplianceCallbackHandler(rec)]})
ComplianceCallbackHandler emits a signed, hash-chained record of every model and
tool call, the cryptographically verifiable audit log the LangChain community asked for in
issue #35691.
Claude Agent SDK (PreToolUse / PostToolUse hooks)
from provenrail.integrations.claude_sdk import provenrail_hooks
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
with fr.record("my-claude-agent") as rec:
options = ClaudeAgentOptions(hooks=provenrail_hooks(rec))
# every tool call the agent makes is recorded off-box
Agno (agent-level tool hooks)
from provenrail.integrations.agno import provenrail_tool_hook
with fr.record("my-agno-agent") as rec:
agent = Agent(model=..., tools=[...], tool_hooks=[provenrail_tool_hook(rec)])
Every tool call an Agno agent makes becomes a signed, hash-chained record, the cryptographic receipt layer the Agno community asked for in issue #7518. Failures are recorded as evidence and re-raised unchanged, so Agno's own error handling is untouched.
Hermes Agent (observer plugin hooks)
from provenrail.integrations.hermes import register_provenrail
def register(ctx): # ~/.hermes/plugins/provenrail/__init__.py
register_provenrail(ctx, agent_name="my-hermes-agent")
Registers on the hermes.observer.v1 contract, so every tool dispatch across the
CLI, gateway, cron, and subagents is recorded. Hermes closed its in-tree audit-log proposal
(issue #487)
precisely because a hash chain verified only against itself proves nothing against an operator
with write access. That is the gap the off-box chain, trusted timestamps, and witnessed
transparency log are built to close.
TypeScript quickstart
import { record } from "provenrail";
await record("my-agent", async (pr) => {
await pr.recordModelCall("openai", "gpt-5", { prompt }, out, { usage });
});
A run recorded in TypeScript is byte-for-byte compatible with one recorded in Python: the same recording server accepts it and the same two verifiers prove it. Node 20+ is required (WebCrypto Ed25519).
Verify a run
Verification trusts neither the agent nor the recording server. Anyone can run it, with no account:
pr verify bundle.json --pin pin.json
To verify your own recorded run after pr quickstart, export it from the
local recording server first (this uses the read token quickstart saves), then verify the bundle:
pr export my-run.json # pulls your sealed run out of the recording server
pr verify my-run.json # recompute everything; trust nobody
Or verify in your browser, with the bundle never leaving your device: provenrail.com/verify. Try the live verified demo or watch it catch a tampered run.
Guardrails and instant alerts
By default Provenrail is a recorder: it observes and proves, it does not intervene. A policy turns it into an enforcer as well. Rules are evaluated at the dispatch boundary, the action is blocked before it runs, and the decision is written into the same signed chain, so the record proves the guardrail was in force and that the call really was stopped.
Nothing is suspicious by default. There is no built-in threat library and no anomaly detection. If you declare no policy, nothing is ever blocked and no alert can fire. That is deliberate: we would rather you state your own rules than have us guess and be wrong in both directions.
Start with the prebuilt packs
You do not have to write rules from a blank page. The catalogue ships packs of prebuilt rules for the things agents actually do damage with, and you enable them by name:
{
"policy": { "use": ["destructive", "secrets", "money"] }
}
| Pack | Covers |
|---|---|
destructive | delete/drop/truncate tools, rm -rf, destructive SQL, unbounded DELETE |
secrets | AWS keys, private key blocks, API tokens, JWTs, .env reads |
money | transfers, payments, refunds, charges (human approval, not a hard block) |
production | deploys, migrations, DNS, force push, terraform destroy |
access | permission grants, IAM changes, chmod 777, MFA disabling |
exfiltration | unbounded SELECT *, outbound uploads, paste sites |
blast-radius | per-session caps on email, messaging, and total tool calls |
You can enable a whole pack or a single rule by id
("use": ["secrets.aws-access-key"]), mix packs with your own rules, and a
misspelled name is rejected rather than skipped. List everything with
pr rules (add --verbose for each rule's false-positive note).
Enabling a pack is not proof of coverage. These rules match tool
names and argument text, and every codebase names its tools differently: a rule
for delete_* does nothing if your tool is called remove_record.
Turn the guess into evidence by checking against a real recorded run:
pr rules --check bundle.json # which rules would match YOUR tool names
Or declare your own rules
No code change is needed, so whoever owns the deployment can set the rules even if they did not write the agent:
{
"policy": {
"rules": [
{"id": "no-destructive-tools", "effect": "deny",
"event_type": "tool_call", "tool": "delete_*",
"reason": "destructive tool"},
{"id": "no-credentials-in-args", "effect": "deny",
"arg_contains": "AKIA[0-9A-Z]{8}",
"reason": "argument looks like an AWS key"},
{"id": "wire-needs-human", "effect": "require_oversight",
"tool": "wire_transfer"},
{"id": "email-burst", "effect": "limit",
"tool": "send_email", "max_per_session": 20}
],
"session_spend_cap_usd": 5.0
}
}
| Effect | What it does |
|---|---|
deny | Blocks the action and records the denial. |
require_oversight | Allows it only if a human-oversight event was recorded in this session first; otherwise blocks. |
limit | Allows up to max_per_session matches, then blocks. |
Rules match on event_type, tool, resource and
provider using case-insensitive globs, plus an optional arg_contains
regex over the call's arguments. A malformed policy is rejected loudly rather
than ignored: an unknown field, a bad regex, a duplicate rule id or a limit with
no cap all raise at startup. A typo that silently disables a guardrail is the worst possible
failure, because you would believe you were protected.
Get told the moment something is blocked
Register a webhook for the policy.denied event. It fires at ingest,
in the same moment the record arrives, not on the anchor schedule:
curl -X POST $SINK/v1/webhooks -H "Authorization: Bearer $ACCOUNT_KEY" \
-d '{"url":"https://ops.example/hooks/pr","events":["policy.denied"]}'
You get one signed POST per blocked action, carrying the rule, the reason, the target, the
session and the record hash. Every delivery is HMAC-SHA256 signed with the per-webhook secret
in the X-Provenrail-Signature header, so you can confirm it came from your recording server.
Delivery runs off the ingest path, so a slow or dead endpoint of yours can never slow down or
block your agent.
Verify it over the raw request body, before any JSON parsing, and compare in constant time:
import hmac, hashlib, json, time
def handle(raw_body: bytes, headers, secret: str):
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, headers["X-Provenrail-Signature"]):
raise ValueError("not from the sink") # never == on a MAC
event = json.loads(raw_body)
# Replay defence is inside the signed body, so it cannot be stripped:
# event["id"] is unique per alert -> drop one you have already handled
# event["at"] is when it was sent -> reject anything older than a few minutes
if seen(event["id"]): return
if time.time() - iso_to_epoch(event["at"]) > 300: raise ValueError("stale")
act_on(event)
A delivery that gets no 2xx is retried twice and then dropped, so your handler
should be idempotent, which the id check above already makes it.
Alert endpoints must be publicly reachable. A webhook pointing at
localhost, a private range, or a link-local address is rejected, and the host is
re-resolved at delivery time as well, so a name that turns internal later stops being
called. This defeats SSRF and DNS rebinding, but it also means you cannot test alerting
against a server on your laptop: use a public endpoint or a tunnel.
The other events are about the record rather than the behaviour:
integrity.tampered, integrity.recovered and
integrity.first_anchor, evaluated after each anchor.
Review what was blocked
pr risk bundle.json # every denial, grouped by rule; exit 1 if any
pr risk bundle.json --json # same, machine-readable for CI
It exits non-zero when anything was blocked, so CI can fail a run in which the agent tried
something forbidden. In the dashboard, denials appear as a red Blocked tile and as red
rows in the session timeline. In the SIEM export
(/v1/streams/{id}/export.ndjson) each denial is one line with the rule and reason,
ready for Splunk, Elastic or Datadog.
Because the decisions live inside the signed chain, pr verify proves they were
recorded at the time and have not been edited since. That is the part a log file cannot do.
pr sidecar in front of the model API and lock egress
to it.Spend: caps that bind, and a bill you can check
An agent that loops overnight is a bill you find out about from the invoice. Provenrail records the token usage each model call reports, prices it, and enforces caps at the same boundary as the guardrails, so the stop is recorded in the same signed chain as everything else.
Cap the spend
Budgets sit next to your rules in .provenrail.json. Scope is
session, day or total; warn_at is a
fraction of the limit that warns without ever changing the verdict.
{
"policy": {
"use": ["destructive", "money"],
"budgets": [
{ "id": "per-session", "scope": "session", "limit_usd": 0.50, "warn_at": 0.8 },
{ "id": "daily", "scope": "day", "limit_usd": 5.00 }
]
}
}
pr guard status shows every budget, what it has spent and what is left.
See what a run cost
pr spend my-run.json # per session and per model
pr spend # the cross-run ledger, all agents
pr spend --agent billing-bot # one agent
Check it against the real bill
Our figure is an estimate from published list prices, so the number that matters is the gap between it and what you were actually charged. Feed in the provider's usage export:
pr reconcile my-run.json --invoice openai-usage.csv
It exits 1 when the invoice bills for a model that never went through the
recorder, which is the finding worth alerting on: spend from an uninstrumented service, a
scheduled job, or a machine outside your deployment. A price variance on a model you
did record is reported for you to read and does not change the exit code, because a
list-price estimate is expected to drift from a negotiated rate.
pr spend --json reports unpriced_calls so a total is never
silently short.Prove a transcript, and disclose a redacted one
By default the record stores a hash of each prompt and response, not the text, so the record itself leaks nothing. To show someone that the transcript you hold is the one that was recorded, check your copy against the fingerprint:
pr verify-content my-run.json --file prompt.json --field request
It exits 0 on a match and 1 when the content is not what was
recorded. Where fields were redacted at capture, pr disclose renders a view of
the bundle with named fields opened for a specific recipient, leaving the rest sealed:
pr disclose my-run.json --openings keys.json
CLI reference
pr runs the wrong program. pr
is also the POSIX paginator from coreutils. On most systems the install directory comes
first on PATH and ours wins; on Windows under Git Bash it does not, and
pr --version answers pr (GNU coreutils) 8.32. Use the module
entry point instead, which nothing can shadow:
python -m provenrail verify bundle.json. Every subcommand is identical.pr undo [last|N] # 0.6.0: restore the working tree to before an agent action
pr stop / pr resume # 0.6.0: refuse, then allow, every agent's tool calls on this machine
pr remote setup ntfy # 0.6.0: approve from your phone; also: test, status, off
pr away / pr back # 0.6.0: send questions to the phone, or ask at this machine again
pr scan [path] # 0.6.0: what in a repository runs or steers an agent
pr brief # 0.6.0: review brief of what the agent changed
pr quickstart # local recording server + config, zero tokens
pr demo # records a session, anchors it, writes bundle.json + pin.json
pr export my-run.json # export your own recorded run, locally
pr verify bundle.json --pin pin.json # verify, trusting nobody
pr activate <key> # store your licence key once; other commands find it
pr anchor-push bundle.json # independent timestamp; sends a fingerprint, no records
pr anchor-verify bundle.json anchor-receipt.json # check a receipt offline
pr attest --blame # signed statement of which commits an AI agent had a hand in
pr attest-anchor ai-attestation.json # independent RFC 3161 time on that statement
pr attest-verify ai-attestation.json # what your customer runs, against their own clone
pr rules # list prebuilt guardrail packs; --check bundle.json for coverage
pr risk bundle.json # every action a policy blocked (exit 1 if any)
pr report --regime eu-ai-act bundle.json --md # map the record to a regime
pr pack bundle.json # self-contained evidence pack (zip) for auditors
pr diff run-a.json run-b.json # diff two runs (exit 1 if they differ)
pr spend bundle.json # what this run cost, per session and per model
pr reconcile bundle.json --invoice bill.csv # recorded spend vs the provider's bill
pr verify-content bundle.json --file prompt.json # prove a transcript you hold was the one recorded
pr disclose bundle.json --openings keys.json # reveal redacted fields to a named recipient
pr ots-verify proof.ots --data-sha256 H # verify a Bitcoin (OpenTimestamps) proof
pr serve --anchor rfc3161 # run the recording server yourself (real trusted time)
pr sidecar --upstream https://api.openai.com # out-of-process capture proxy
pr witness --log <origin>=<pubkey> # independent witness on separate infra
Self-host the recording server
The recording server (the "sink" in the API and the source) is the append-only service that receives records. You run it; your records never reach us. For real third-party trusted time, anchor with RFC 3161:
pr serve --anchor rfc3161 --tsa https://freetsa.org/tsr
Or with Docker:
docker compose up
pr sidecar as an outbound proxy and
lock model egress to it, so capture is mandatory rather than a default. Add --fail-closed
to refuse any call that cannot be recorded.Anchor your chain
Everything above proves your records are internally consistent. It cannot prove they existed before the argument started, because you signed them and you hold them. That is the one thing you cannot do for yourself, and it is what anchoring is for: you publish a fingerprint of your chain somewhere you do not control, and later you can show the fingerprint has not moved.
pr anchor-push bundle.json
That is the whole command: it defaults to the hosted service and to the licence key
pr activate stored, and writes the receipt to anchor-receipt.json.
Point it elsewhere with --url, or pass a different key with --key.
pr activate <key> once. One anchor per account, so you can hold a receipt
with a public authority's signature on it before deciding whether to pay for more. After
that it is anchored statements, and the key you already have names your
account and your plan, so the service verifies it without a lookup and there is nothing else
to sign up for.What actually travels
Three things: a stream label you choose, a 64-character fingerprint of your records (a SHA-256 Merkle root over their hashes), and how many records it covers. You keep every record. There is no field in the request a record could arrive in, so we cannot read your prompts, reconstruct your chain, or show anyone what your agents did, because we are never sent it. That is the reason we can host this part and not the rest.
What you get back
A receipt carrying an RFC 3161 trusted timestamp from a public authority, so the date is not ours to assert either, and a URL an auditor can open with no account and no permission from you. Save the receipt:
The receipt is written for you as anchor-receipt.json (use
--receipt-out for a different path). Then anyone can check it against your
records, offline, without asking us anything:
pr anchor-verify bundle.json anchor-receipt.json
That recomputes the root from the records in the bundle, compares it with the one under the signature, validates the timestamp's certificate chain, and verifies the bundle's own hash chain. If someone edited a record after it was anchored, this is what says so.
What it refuses
Coverage of a stream only ever grows. If you anchor 1,000 records, something goes wrong at 400, and you try to anchor the shorter chain, the service refuses rather than signing over your own history. The same length cannot get two different roots either. An exact repeat of a request you already made returns the original receipt rather than a second one, so retries are safe.
pr serve --anchor rfc3161 on
a machine you control accepts the same command with a different --url. You get the
append-only history and the receipt and a real trusted timestamp; what you do not get is
independence from yourself, which is the half worth paying for.AI code attribution
Supplier contracts have started asking which parts of a codebase an AI agent wrote. The
standard answer, a Co-authored-by: trailer or a git note, is written by the
party being asked and can be rewritten afterwards, so it is a claim rather than evidence.
pr attest --blame # writes ai-attestation.json, signed with your device key
pr attest-anchor ai-attestation.json # RFC 3161 timestamp from an independent authority
pr attest-verify ai-attestation.json --receipt attest-receipt.json
pr attest reads the commits in a range, detects AI-assisted ones from commit
trailers and author or committer identity, and names the source of every finding so a reader
can weigh it rather than trust it. --blame adds what share of the lines currently
in the tree came from AI-assisted commits; lines from commits outside the range are reported
as unattributed, never as human. --records bundle.json raises the evidence grade
of any commit authored inside a recorded agent session, because a signed record is not
something the committer typed.
What signing buys is specific: git commit ids are content hashes, so the document cannot
later be pointed at a different tree, and cannot be quietly rewritten once a dispute begins.
What it does not buy is the truth of the findings, and the document says so in its own
limits field. A commit whose author stripped the trailer reads as human-authored.
Detection covers nine named tools plus generic trailers, so AI involvement is understated
rather than overstated.
pr attest-verify is what the receiving side runs. It checks the signature, that
every attested commit exists in their clone with the same author and dates, and that re-running
the detectors reaches the same verdict. A document that passes the first two and fails the
third was edited before signing, which a signature alone cannot catch. Full detail:.
Seats and SSO on a server you run
These are capabilities of the recording server, which is AGPL and yours to run. They are not something we sell: a server you host can carry up to 10 members with role-based access and can be pointed at your own identity provider, so staff sign in with the IdP you already use. Everything is account-authenticated against your own server, and the integrity guarantee is the same whether you pay us anything or not.
Members and roles
Invite a teammate and they get their own key (shown once). Four least-privilege roles: owner (billing and everything), admin (manage streams, members, webhooks), member (run agents, export their own streams), and viewer (read only). An actor can only grant roles at or below its own.
curl -X POST $URL/v1/members -H "Authorization: Bearer $ACCOUNT_KEY" \
-d '{"role":"admin","email":"[email protected]"}'
{
"member_id": "mbr_9cf5...",
"role": "admin",
"api_key": "pr_mk_rPk8...", # shown once, store it now
"note": "store this key now; it is shown only once"
}
Single sign-on (OIDC)
SSO is API-first: you configure your IdP once, then your staff present an ID token from that IdP to receive a session key. Provenrail makes no network call (the JWKS is pinned out of band), and validation is strict, only RS256 and EdDSA are accepted, with issuer, audience and expiry all checked. New users are provisioned just-in-time at the default role.
# 1. Owner configures the org IdP once (issuer, audience, pinned JWKS, default role)
curl -X PUT $URL/v1/sso/config -H "Authorization: Bearer $ACCOUNT_KEY" \
-d '{"issuer":"https://acme.okta.com","audience":"provenrail",
"jwks":{ ... },"default_role":"member","email_domain":"acme.com"}'
# 2. A teammate authenticates with your IdP, then exchanges the ID token for a member key
curl -X POST $URL/v1/sso/login -d '{"id_token":"eyJhbGciOiJSUzI1Ni..."}'
{
"member_id": "mbr_9ca5...",
"role": "member", # JIT-provisioned at the default role
"api_key": "pr_mk_lAFz..."
}
kid; alg: none and HMAC algorithms are rejected, so
there is no algorithm-confusion bypass. An optional email_domain restricts which
addresses may provision. Every login is written to the tamper-evident audit log.Licensing
Provenrail is open-core and dual-licensed:
- MIT covers the client SDKs, the
pr verifyverifier, the in-browser verifier, and the spec. Use them anywhere, including commercially. - AGPL-3.0-or-later covers the server (the sink). If you run a modified sink as a network service, AGPL applies. A commercial license removes that obligation and is included with a paid plan.
Get a commercial license key from your account, then activate it on your server:
pr activate prl_live_... # verifies offline, stores the key
pr serve # now runs at your licensed tier
Verification is fully offline: the package ships the Ed25519 public key and checks the signed key locally, so nothing phones home and a licensed build works air-gapped. The license is a commercial control, not DRM; the open-source integrity guarantee is identical on every tier.
Source and spec
The wire format and verification steps are a frozen, public specification, so a third party can write an independent verifier and check the same bundles. The in-browser verifier at /verify is a second, independent implementation of that spec, kept in lockstep with the Python one. Source code is open under the licenses above.