Reference¶
Everything technical: every CLI command, the evidence-log format, hypothesis gating, the Rust API, the security model, and troubleshooting. For a guided first run, start with Getting Started. For the on-disk format, see FORMAT.md.
Why Litatoli?¶
SOC 2, HIPAA, IEC 62304 and the rest all assume the person who approved your code understood it. AI-generated code strains that assumption, and when the model writes both the implementation and the tests, the coverage reflects the model's reading of the requirements rather than an independent check of them.
Litatoli records the difference — structurally, not on the model's word:
- Command execution with evidence — captures exit code, stdout/stderr, FS diffs, network activity, all keyed-BLAKE3-signed
- Git verification — proves a commit exists, a push reached the remote, commit details match expectations
- Hypothesis gating — blocks costly actions (cargo build, docker-compose up, database migrations) unless chain-validated prior evidence exists
- Message scanning — detects success claims in agent output and verifies them against reality
- Shell injection prevention — POSIX-compliant lexer rejects injection attempts before execution
- Database state verification — hashes query output to prove database state at a point in time
- Network activity capture — monitors
/proc/net/tcp{,6}to record new outbound TCP connections during execution - Append-only evidence log — keyed-BLAKE3-signed, chain-linked JSONL for forensic audit trails
Command walkthrough¶
Run a command and capture evidence¶
Output:
{
"overall_ok": true,
"specs": [
{
"id": "run:echo hello world",
"ok": true
}
],
"metrics": {
"command": "echo hello world",
"exit_code": 0,
"stdout": "hello world\n",
"stderr": "",
"elapsed_ms": 5,
"verified": true,
"evidence_type": "command_run",
"blake3_signature": "a1b2c3d4...",
"network_activity": {
"new_connections": [],
"outbound_count": 0
}
}
}
Verify a file exists¶
{
"overall_ok": true,
"specs": [{ "id": "verify-file:./README.md", "ok": true }],
"metrics": {
"evidence_type": "file_existence",
"details": { "path": "./README.md", "exists": true, "size": 1234, "content_hash": "abc123..." }
}
}
Verify with expected hash¶
litatoli verify-file ./output.json --expected-hash 5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03
Returns "hash_match": true and exit code 0 if the SHA-256 matches, or "hash_match": false and exit code 1 if not.
Capture evidence to a log file¶
This creates a chain-linked JSONL evidence file. Each entry is keyed-BLAKE3-signed and linked to the previous entry.
Gate a costly action¶
litatoli evaluate-hypothesis \
--hypothesis "The code compiles" \
--proposed-command "cargo build --release" \
--log-file evidence.jsonl
If no prior related evidence (e.g., a passing cargo check) exists in the log, Litatoli blocks the action:
{
"overall_ok": false,
"specs": [{ "id": "evaluate-hypothesis", "ok": false, "errors": ["1. Run: cargo check\n2. Run: cargo test"] }],
"metrics": {
"action_blocked": true,
"detected_pattern": "cargo-build-release",
"cost_estimate_secs": 180,
"required_proof_steps": ["cargo check", "cargo test", "cargo clippy"]
}
}
CLI reference¶
Every command outputs a JSON report to stdout. Logs go to stderr.
Global flag: --log-level <LEVEL> (default: warn, options: trace, debug, info, warn, error)
Exit codes: 0 = verified/success, 1 = fabrication/mismatch/blocked, 2 = I/O or parse error.
run — Execute with evidence capture¶
| Option | Default | Description |
|---|---|---|
--cwd <PATH> |
. |
Working directory |
--timeout <SECS> |
— | Kill after N seconds (SIGTERM, then SIGKILL after 2s grace) |
--watch-dir <PATH> |
— | Snapshot directory before/after for FS diff |
--log-file <PATH> |
— | Append to BLAKE3-signed JSONL evidence log |
Captures: exit code, stdout, stderr, elapsed time, BLAKE3 signature, filesystem diff (if --watch-dir), new TCP connections (Linux).
attest — Sign a pre-captured result¶
| Option | Default | Description |
|---|---|---|
--log-file <PATH> |
— | Append to evidence log |
--run-id <ID> |
— | Correlation id (ADR-0005) sealed into the entry's signed top-level run_id field, so the whole run is queryable with query --run-id |
Reads JSON from stdin (max 100MB). Signs the result with keyed BLAKE3 without re-executing the command. Evidence type: command_attest.
attest-ot-run — Sign an OT simulation run¶
echo '{"simulator_image_hash":"abc...","agent_code_hash":"def...","modbus_blocks_count":3,"iec62443_sl_target":"SL-T 1","outcome":"success","sandbox_id":"a1b2c3d4...","inspection_interface":"tap-ot-a1b2c3d4","raw_log":null}' | \
litatoli attest-ot-run \
--simulator-image-hash "abc..." \
--agent-code-hash "def..." \
--modbus-blocks-count 3 \
--iec62443-sl-target "SL-T 1"
| Option | Default | Description |
|---|---|---|
--simulator-image-hash <SHA256> |
required | SHA-256 of the Ghost PLC Docker image (must match SandboxProfile.ghost_plc_image_hash) |
--agent-code-hash <SHA256> |
required | SHA-256 of the agent code under test |
--modbus-blocks-count <N> |
required | Number of Modbus writes blocked by the eBPF filter during the run |
--iec62443-sl-target <LEVEL> |
required | IEC 62443 Security Level target (e.g. "SL-T 1") |
--log-file <PATH> |
— | Append to evidence log |
Reads OtRunInput JSON from stdin (max 100MB). All four CLI arguments are authoritative — the handler cross-checks each against the corresponding stdin field and fails with exit code 1 (fabrication) on any mismatch. The signed OtRunOutcome record is built from the validated CLI values, cryptographically binding the evidence to the CLI invocation contract. Evidence type: ot_simulation_run.
attest-ai-inference — Seal an AI inference session¶
litatoli attest-ai-inference --bim-digest blake3:<hex> --model-digest blake3:<hex> \
[--log-file <PATH>] [--sync-every <N>] < records.jsonl
Reads AiInferenceRecord JSONL from stdin — one record per line — and appends a chained, signed ai_inference entry per record as it arrives. One long-lived process seals a whole session, so per-inference cost is an append, not a process spawn. Every record's bim_digest/model_digest must match the CLI arguments (a session is pinned to ONE build); any mismatch or parse error fails closed. --sync-every N fsyncs the log every N records (default 1 = every record durable before its ack; N>1 amortizes fsync cost but a crash may drop records since the last boundary).
verify-file — Check file existence and hash¶
Without --expected-hash: verifies file exists and reports its SHA-256.
With --expected-hash: additionally verifies the hash matches.
verify-git-commit — Verify a commit exists¶
Runs git show --no-patch and returns commit details (author, date, message).
Returns exit 1 (Fabrication) if the commit doesn't exist.
verify-git-push — Verify a commit reached a remote¶
Runs git ls-remote and verifies the commit hash matches the remote branch head.
evaluate-message — Scan agent text for claims¶
| Option | Default | Description |
|---|---|---|
--cwd <PATH> |
. |
Git repo root |
--remote <NAME> |
— | Override remote for push verification |
--branch <NAME> |
— | Override branch for push verification |
--file-path <PATH> |
— | File to verify for creation claims |
Detects success keywords (success, completed, pushed, committed, created, deployed, done, etc.) and routes to the appropriate verifier:
| Claim type | Detection | Verification |
|---|---|---|
| Git push | Message contains "push" + hex hash | git ls-remote against remote |
| Git commit | Message contains "commit" + hex hash | git show for commit details |
| File creation | Message contains file creation keywords | File existence + SHA-256 |
| Generic success | Any other success keyword | Returns requires_retry: true |
evaluate-hypothesis — Gate costly actions¶
litatoli evaluate-hypothesis --hypothesis <TEXT> --proposed-command <CMD> [--log-file <PATH>] [--patterns <TOML>]
Blocks costly actions unless prior chain-validated evidence exists. Proof must be both semantically related and chain-validated — a passing cargo check unblocks cargo build, but an unrelated echo hello does not, and a tampered or reordered log entry is rejected.
| Option | Default | Description |
|---|---|---|
--log-file <PATH> |
— | JSONL evidence log to check for prior proof |
--patterns <TOML> |
— | Custom costly action patterns (merged with built-in) |
check-proof — Read-only proof query¶
Unlike evaluate-hypothesis (which blocks), check-proof is a pure query: it reports whether sufficient chain-validated proof exists for a proposed command, without blocking anything. This enables predictive workflows where an orchestrator checks proof availability before planning, so agents know upfront which lightweight steps to run first — zero wasted LLM roundtrips on reactive rejections.
{
"overall_ok": true,
"metrics": {
"proof_found": true,
"matching_entry_seq": 7,
"detected_pattern": "cargo-build-release",
"cost_estimate_secs": 180,
"proof_keywords": ["cargo check", "cargo test", "cargo clippy"]
}
}
When proof_found is false, the response includes required_proof_steps — the lightweight commands to run first.
query — Return one run by its correlation id¶
Returns every entry whose signed top-level run_id (ADR-0005) matches — a structured-field match, not a text search of the payload. Exits 1 when no entry carries the id. --json emits the matching entries as a JSON array instead of a summary. This command only SELECTS; pair it with verify-chain for authenticity.
list-patterns — Export gating rules¶
Outputs all costly action patterns as a JSON array. Enables orchestrators to discover gating rules at boot time and generate tool schemas with evidence pre-conditions.
[
{
"label": "maturin-develop",
"substring": "maturin develop",
"cost_estimate_secs": 90,
"proof_steps": ["Run an isolated unit test...", "Confirm with cargo test..."],
"proof_keywords": ["cargo test", "cargo check", "rustc"]
}
]
Custom patterns (TOML)¶
Both evaluate-hypothesis and check-proof accept --patterns <file.toml> to load custom costly action patterns. Custom patterns are merged with built-in patterns; if a custom pattern has the same label as a built-in, it replaces the built-in.
[[patterns]]
label = "terraform-apply"
substring = "terraform apply"
cost_estimate_secs = 120
proof_steps = ["Run terraform plan first", "Validate with terraform validate"]
proof_keywords = ["terraform plan", "terraform validate"]
[[patterns]]
label = "npm-publish"
substring = "npm publish"
cost_estimate_secs = 30
proof_steps = ["npm test"]
proof_keywords = ["npm test", "npm run test"]
verify-db-state — Verify database query result¶
litatoli verify-db-state \
--connection-string "postgres://user:pass@localhost/db" \
--query "SELECT count(*) FROM users" \
--expected-hash <SHA256>
Runs the query via psql (PostgreSQL) or sqlite3 (SQLite), hashes the raw output with SHA-256, and compares against the expected hash. Connection strings are redacted in output — credentials never appear in reports.
| Backend | Detection | Tool |
|---|---|---|
| SQLite | URL starts with sqlite:, or ends with .db/.sqlite/.sqlite3 |
sqlite3 |
| PostgreSQL | anything else | psql -t -A -c |
batch-verify — Parallel verification¶
echo '[{"type":"file","path":"./out.json"},{"type":"git-commit","hash":"abc1234","cwd":"."}]' | litatoli batch-verify
| Option | Default | Description |
|---|---|---|
--spec <PATH> |
stdin | JSON file path (use - for explicit stdin) |
Runs all specs in parallel via rayon. Supported spec types:
| Type | Required fields | Optional fields |
|---|---|---|
file |
path |
expected_hash |
git-commit |
hash |
cwd (default .) |
git-push |
remote, branch, hash |
cwd (default .) |
message |
text |
cwd (default .) |
db-state |
connection_string, query, expected_hash |
— |
export-attestations — in-toto / DSSE interop export¶
litatoli export-attestations --log-file evidence.jsonl --out attestations.jsonl [--dsse] [--sigstore-bundle] [--chain-summary]
Projects the evidence log into in-toto v1 Statements (JSONL, one per
entry), optionally wrapped in DSSE envelopes (--dsse) signed with the
configured Ed25519 key, or in Sigstore bundles (--sigstore-bundle) that
cosign verify-blob-attestation --bundle reads directly. Plain DSSE is the
portable default — any DSSE / in-toto tool reads it; the Sigstore bundle is a
cosign-targeted wrapping of the same signed envelope.
| Option | Default | Description |
|---|---|---|
--log-file |
required | Append-only JSONL evidence log to export |
--out |
required | Destination file (attestations, one per line) |
--dsse |
off | Wrap each Statement in a signed DSSE envelope (requires an Ed25519 key — see Key resolution) |
--sigstore-bundle |
off | Wrap each signed DSSE envelope in a Sigstore bundle (v0.3), read directly by cosign --bundle. Implies --dsse. |
--chain-summary |
off | Append one Statement covering the whole chain (file digest, head MAC, seq range, signer pubkeys, trailing checkpoint) |
Mapping: subject[0] is the entry itself (unkeyed BLAKE3 digest of the exact
raw log line — re-derivable by anyone holding the log, no MAC key needed);
entries whose payload carries model_digest / bim_digest get extra model /
bim subjects so attestations can be looked up by artifact digest; the
predicate is the original entry verbatim
(predicateType: https://litatoli.dev/attestations/evidence/v1).
cosign interop. Two automated tests in cli/tests/integration.rs cover it. test_export_attestations_dsse_signature_verifies recomputes the DSSE Pre-Authentication Encoding and verifies the Ed25519 signature with the exported public key on every run. test_cosign_binary_verifies_dsse_attestation runs the actual cosign binary against an exported attestation — gated on cosign being installed, so it runs wherever cosign is present (verified against cosign v3.1.3) and is skipped otherwise. Reproduce it by hand:
litatoli export-attestations --log-file ev.jsonl --out att.jsonl --sigstore-bundle
head -1 att.jsonl > attestation.sigstore.json # first bundle (entry seq 1)
head -1 ev.jsonl | tr -d '\n' > line1 # the raw line it attests
cosign verify-blob-attestation --bundle attestation.sigstore.json --new-bundle-format \
--key litatoli.pub --insecure-ignore-tlog \
--type https://litatoli.dev/attestations/evidence/v1 \
line1 # -> Verified OK
--sigstore-bundle emits the v0.3 bundle cosign reads with --bundle — the
format cosign is standardising on, not the older --signature flag it has
deprecated toward removal. --new-bundle-format is transitional: cosign is
making that format the default, at which point the flag goes away.
(litatoli.pub is the Ed25519 public key from export-pubkey, converted to
PEM/SPKI; --insecure-ignore-tlog because these attestations are not uploaded
to a public transparency log.)
Plain --dsse stays the portable default. A Sigstore bundle is a
cosign-targeted wrapping: it couples to Sigstore's evolving schema, blanks the
self-describing keyid (cosign matches by --key), and expects a transparency
log litatoli does not use — while a plain DSSE envelope is read by any
DSSE / in-toto tool. Use --sigstore-bundle for cosign, --dsse for everyone
else.
The full on-disk format is specified in FORMAT.md — precisely
enough to build an independent verifier with no litatoli code (a ~900-line
Python reference lives at pimatika/tools/verify_bundle.py).
Scope: this is a presentation-layer projection for interop and
auditor familiarity. A DSSE verifier checks that each attestation is signed by
the expected key; it does not understand seq/prev_hash continuity,
Merkle checkpoints or BIM cross-anchoring — chain integrity still requires
verify-chain. The canonical JSONL format is unchanged. Export fails closed:
any unparseable log line aborts the whole export (never a silent partial view).
sign-attestation — DSSE-sign an external in-toto Statement¶
Wraps an existing in-toto v1 Statement built by any producer (e.g. nazelo-trust's runtime-enforcement attestation) in a DSSE envelope signed with the configured Ed25519 key. Fail-closed: refuses anything that is not a well-formed in-toto v1 Statement. (export-attestations projects this log into Statements; sign-attestation signs a Statement you already hold.)
secure-run — execution without a shell¶
| Option | Default | Description |
|---|---|---|
--cwd <PATH> |
. |
Working directory |
--timeout <SECS> |
— | Kill after N seconds |
--no-verify |
off | Skip post-execution verification |
--unsafe-shell |
off | Pass raw string to /bin/sh -c (DANGEROUS) |
What this is, and where it stops. The command is tokenized and executed
directly — no shell interprets it, so there is nothing for a metacharacter to
mean. That refuses shell injection; it does not make the command safe, and
--unsafe-shell hands the raw string to /bin/sh -c and gives all of it back.
Commands are tokenized with a POSIX-compliant shell lexer. Shell metacharacters (;, |, &, `, $()) are treated as literal data, preventing injection attacks.
Post-execution verification is automatic for critical commands:
| Command pattern | Verification |
|---|---|
git push |
Verifies commit reached remote via git ls-remote |
git commit |
Verifies commit exists via git show |
rm |
Exit code check |
mv |
Exit code check |
cp |
Exit code check |
Git push parsing is token-aware — flags (--force, -u, --set-upstream, --force-with-lease) in any position are correctly skipped when extracting remote/branch targets.
The evidence chain, end to end¶
The commands above produce records. This section is how those records become something an auditor can act on.
keygen — Create the two keys¶
Sealing needs two secrets and this makes both:
| Key | Where | What it does |
|---|---|---|
| Ed25519 | LITATOLI_ED25519_KEY_FILE / ~/.config/litatoli/ed25519.key, or --out |
The public-key signature a third party verifies. This is what an auditor pins. |
| keyed BLAKE3 | LITATOLI_KEY_FILE / ~/.config/litatoli/signing.key, or --mac-out |
The MAC that chains entries together. Never leaves your trust domain. |
The MAC key is skipped when LITATOLI_SIGNING_KEY is already set — the env var
wins at load, so writing a file that is never read would be a lie on disk.
--force rotates. Nothing is ever auto-generated: a key that appears by itself
has no rotation or distribution story.
Publish the Ed25519 public key. litatoli export-pubkey prints it.
checkpoint — Commit to everything so far¶
Appends a signed entry carrying the Merkle root of every entry before it.
verify-chain validates checkpoints automatically, which catches in-place
tampering below one. It is not an anti-backdating defence on its own —
someone holding the signing key can re-sign a whole fabricated history,
checkpoints included. Timestamp it (stamp) or witness it to fix it in time.
verify-chain — What an auditor runs¶
litatoli verify-chain --log-file evidence.jsonl \
--expected-pubkey <64 hex chars> \
--expected-head <blake3_signature of the last entry>
| Option | Why |
|---|---|
--expected-pubkey |
Required unless you pass --allow-unpinned. A log signed end to end by one attacker's key is perfectly self-consistent and passes every internal check. |
--trust-anchor + --key-registry |
Pin a ROOT key instead of each leaf, so signer keys can rotate without re-pinning. |
--expected-head |
The only thing that detects entries cut from the END. See "The completeness gap" below. |
--allow-unpinned |
Self-consistency only. Proves nothing about who signed. |
Read proves, not overall_ok. overall_ok is the same true whether
you pinned anything or not; proves names what the pass actually established,
and the command prints the log's head so you can pin it next time.
witness-submit — Anchor a head outside the log¶
litatoli checkpoint --log-file evidence.jsonl
litatoli witness-submit --log-file evidence.jsonl --witness https://witness.example/witness
Backends: file:<path> (local), http(s)://<url> (self-hosted or managed), or
rekor: (the public Sigstore transparency log).
A witness is an append-only registry of heads that only ever moves forward per workload. That single rule is what makes a rolled-back or truncated log detectable: the cut version reports a head behind what the witness already recorded, and is refused.
Running a witness¶
Three commands, for the service side rather than the producer side:
# Receive a head over the wire, check monotonicity, sign and record it.
echo '<Head JSON>' | litatoli witness-record --witness file:/var/lib/litatoli/witness.jsonl
# Serve the latest head for a workload (raw WitnessedHead JSON).
litatoli witness-latest --witness file:/var/lib/litatoli/witness.jsonl --workload-id blake3:…
# Sign a head WITHOUT storing it — for a witness whose registry is a database.
echo '<Head JSON>' | litatoli witness-sign
witness-sign enforces nothing: monotonicity is a property of a registry and
it has none. A caller that stores without checking first will have the witness
sign a rollback.
Exit codes are distinct because a service maps them to HTTP: 1 = the evidence says no (non-monotonic submission, or no record for that workload), 2 = bad input or a broken backend.
litatoli-saas ships the HTTP service these back, over Postgres.
verify-ed25519 — Verify per-entry signatures (pinned)¶
litatoli verify-ed25519 --log-file ev.jsonl --expected-pubkey <64-hex>
# key rotation: pin the ROOT and authorize leaves via a signed registry
litatoli verify-ed25519 --log-file ev.jsonl --trust-anchor <root-64-hex> --key-registry registry.json
Verifies the Ed25519 signature on every entry that carries one. A trust anchor is REQUIRED by default: pin the trusted key with --expected-pubkey (or the LITATOLI_ED25519_PUBKEY env var). Without one, verification only proves each entry is self-consistent with whatever key it embeds — an attacker who rewrote the whole log with their own keypair would pass; pass --allow-unpinned to run that weaker self-consistency-only check explicitly. Unlike verify-chain, this catches per-line forgery only — it does not check seq/prev_hash continuity, so it will not detect an entry deleted or reordered within the log. With --trust-anchor + --key-registry, each entry's signing key must be authorized (role evidence) by a registry signed by that root and valid at the entry's timestamp — this is what allows key rotation without re-pinning.
stamp — RFC 3161 trusted timestamp¶
Stamps entry <seq> with an RFC 3161 timestamp from a TSA (default http://timestamp.digicert.com; requires the curl binary). The token is persisted as a sidecar at <log_dir>/timestamps/<seq>.tsr. With --tsa-ca the response is FULLY validated — message imprint + nonce echo + CMS signature + TSA certificate chain, via openssl ts -verify — and a token that fails is rejected, never stored (requires openssl). Without --tsa-ca you must pass --allow-unverified-tsa (only the PKIStatus is checked), so an unvalidated stamp can't be mistaken for a verified RFC 3161 timestamp — validate the .tsr yourself later.
Key registry — rotate signers under one pinned root¶
The trust-anchor mode of verify-ed25519 / verify-chain reads a signed registry of authorized signer keys, so keys can rotate without re-pinning every leaf. The ROOT key signs the registry; auditors pin the root, not each key.
# Create: the root signs a list of {pubkey, role, not_before, not_after?, revoked_at?} read on stdin.
echo '[{"pubkey":"<64-hex>","role":"evidence","not_before":"2026-01-01T00:00:00Z"}]' \
| litatoli registry-create --out registry.json
# Rotate a new key in (its own validity window), re-signed with the root:
litatoli registry-add-key --registry registry.json --pubkey <64-hex> \
--role evidence --not-before 2026-06-01T00:00:00Z
# Revoke a key (sets revoked_at) and re-sign:
litatoli registry-revoke-key --registry registry.json --pubkey <64-hex> [--at <ISO-8601>]
# Auditor path — verify the registry against the pinned root (no secret needed):
litatoli registry-verify --registry registry.json --trust-anchor <root-64-hex>
registry-create resolves the root Ed25519 key the usual way (LITATOLI_ED25519_KEY / _KEY_FILE / ~/.config/litatoli/ed25519.key); the registry's root_pubkey is that key's own public half — pin THAT as the trust anchor.
Attributing findings to a clause¶
Provenance answers who wrote what, and what the tests said. It does not say
which clause of IEC 62304 this satisfies. That is
litatoli-scan, a companion package:
It is not installable from public PyPI yet — its two engines (lisaba for
the standards registry, muundo for the code graph) are on no public index.
See scan/README.md for the real install path — both are
built from their repositories.
It scans a source tree against the standard's structural rules and seals every clause claim into a chain — including a claim that says "nothing found", since a clean scan is a result someone will want signed.
--log-file strict error handling¶
When --log-file is provided, evidence persistence is mandatory:
- Log open failure → exit code 2 with structured error report
- Log append failure → exit code 2 with structured error report
Evidence is never silently skipped. If the caller requests a log file, a write failure means the run is not trustworthy.
Evidence log format¶
Litatoli writes keyed-BLAKE3-signed, chain-linked JSONL (one entry per line):
{"seq":1,"timestamp":"…","evidence_type":"command_run","payload":{…},"blake3_signature":"a1b2…","key_id":"f03e…","algorithm":"blake3-keyed"}
{"seq":2,"prev_hash":"a1b2…","timestamp":"…","evidence_type":"git_push","payload":{…},"blake3_signature":"d4e5…","key_id":"f03e…","algorithm":"blake3-keyed"}
Entry fields¶
| Field | Type | Description |
|---|---|---|
seq |
u64 | 1-based consecutive sequence number |
prev_hash |
string | blake3_signature of the previous entry; omitted on the first entry (seq=1) |
timestamp |
string | ISO-8601 UTC |
evidence_type |
string | command_run, command_attest, git_push, git_commit, etc. |
payload |
object | Event-specific data |
blake3_signature |
string | 64-char hex keyed BLAKE3 MAC |
key_id |
string | First 16 hex chars of BLAKE3(key) — identifies which key signed |
algorithm |
string | Always blake3-keyed |
Signing¶
Every entry is signed over a canonical signing payload: a compact JSON object with a fixed field order (a struct, not a map):
{"seq":…,"prev_hash":…,"timestamp":…,"evidence_type":…,"payload":…,
"key_id":…,"algorithm":"blake3-keyed"[,"ed25519_alg":"ed25519",
"ed25519_pubkey":…][,"run_id":…]}
prev_hash is always present here (JSON null on the first entry) even though
it is omitted from the stored entry. The ed25519_* and run_id fields
appear only when the entry carries them. payload is serialized with
recursively sorted keys and compact separators. Both the keyed-BLAKE3 MAC and
the optional Ed25519 signature cover exactly these bytes. The full rule is in
FORMAT.md §2.
Chain integrity¶
Entries form a hash chain. On read, Litatoli verifies:
- Signature — the keyed BLAKE3 MAC matches the recomputed value
- Sequence —
seqvalues are consecutive starting from 1 - Linkage — each entry's
prev_hashequals the previous entry's signature
Any tampering, deletion, insertion, or reordering breaks the chain. Verification stops at the first invalid entry — all subsequent entries are rejected regardless of their content.
Key resolution¶
The 32-byte BLAKE3 MAC key is resolved from (in order):
LITATOLI_SIGNING_KEYenvironment variable (hex-encoded, 64 chars)- File at
LITATOLI_KEY_FILEenv var path (raw 32 bytes) ~/.config/litatoli/signing.key(raw 32 bytes, mode0600) — read if present
There is no auto-generation: if none of these yields a key, signing and
log-write operations fail with an error. LITATOLI_SIGNING_KEY (or a key file)
must be configured — a silently auto-minted per-host MAC key has no
rotation or distribution story. In production (ENVIRONMENT=production or
staging) the CLI fails loudly at startup (exit code 2) unless at least one
signing key is set — BLAKE3 (LITATOLI_SIGNING_KEY) or Ed25519
(LITATOLI_ED25519_KEY).
Every entry carries the keyed-BLAKE3 MAC — it is mandatory and chains the entries. When an Ed25519 key is configured, the entry also carries an Ed25519 signature over the same bytes: the non-repudiable, publicly verifiable one (its public key is embedded in every entry, so logs verify without any shared secret). With no Ed25519 key provisioned, a log is BLAKE3-only.
Hypothesis gating¶
Litatoli prevents agents from executing costly actions without evidence. Two modes are available:
- Reactive (
evaluate-hypothesis): Blocks the action and returns proof steps. Best as a safety net. - Predictive (
check-proof): Read-only query — reports whether proof exists. Best for orchestrator planning: the agent learns what it needs before attempting the action, saving LLM roundtrips.
Costly action patterns (built-in)¶
| Pattern | Estimated cost | Valid proof commands |
|---|---|---|
cargo build --release |
180s | cargo check, cargo test, cargo clippy |
cargo build |
60s | cargo check, cargo test, cargo clippy |
cargo compile |
60s | cargo check, cargo test, cargo clippy |
maturin develop |
90s | cargo test, cargo check, rustc |
maturin build |
120s | cargo test, cargo check, rustc |
make compile |
60s | make check, make lint, make test, cargo check |
docker-compose up |
45s | docker-compose ps, docker ps, docker-compose config |
docker-compose run --rm |
60s | docker-compose exec, docker exec, docker-compose ps |
dropdb / DROP DATABASE |
30s | psql, SELECT, pg_dump |
CREATE DATABASE |
15s | psql, SELECT, createdb |
Proof requirements¶
Proof must satisfy all of:
- Exists in the evidence log — a command_run or command_attest entry
- Semantically related — the command contains a recognized proof keyword for the pattern
- Successful — exit_code == 0
- Chain-validated — the entry is part of an unbroken chain (tampered/reordered entries don't count)
An unrelated passing command (e.g., echo hello) does not unblock cargo build.
Predictive workflow (recommended for orchestrators)¶
Instead of letting agents hit the gate reactively, orchestrators should use the predictive pattern:
- At boot, call
litatoli list-patternsto discover all gating rules - Inject rules into tool schemas / system prompts so agents know upfront
- Before executing a costly command, call
litatoli check-proofto verify evidence exists - If
proof_found: false, inject therequired_proof_stepsinto the agent's plan — not as a rejection error
This avoids wasted LLM tokens on reactive block-retry cycles.
Library usage (Rust)¶
use std::path::Path;
use litatoli_core::{
executor::run_command,
git::verify_commit,
evidence_log::AppendOnlyLog,
hitl_triggers::evaluate_hypothesis,
check_proof, list_patterns, load_patterns_from_file,
secure_shell::SecureShell,
};
// Execute with evidence capture
let outcome = run_command("make", &["test".into()], Path::new("."), Some(60), Some(Path::new("./src")))?;
assert!(outcome.exit_code == 0);
println!("BLAKE3 signature: {}", outcome.blake3_signature.unwrap());
// Verify a git commit
let commit = verify_commit(Path::new("."), "abc1234")?;
println!("Author: {}, Date: {}", commit.author, commit.date);
// Append to evidence log (keyed BLAKE3, chain-linked)
let mut log = AppendOnlyLog::open(Path::new("evidence.jsonl"))?;
log.append_evidence("command_run", serde_json::to_value(&outcome)?)?;
// Command execution without a shell
let shell = SecureShell::new(Path::new("."));
let result = shell.run("git push origin main", true, Some(30), false);
println!("verified: {}, exit: {}", result.verified, result.exit_code);
// Predictive proof check (read-only, no blocking)
let proof = check_proof("cargo build --release", Path::new("evidence.jsonl"));
if !proof.proof_found {
println!("Need proof first: {:?}", proof.required_proof_steps);
}
// Discover all gating rules
let patterns = list_patterns();
for p in &patterns {
println!("{}: costs ~{}s, needs {:?}", p.label, p.cost_estimate_secs, p.proof_keywords);
}
// Load custom patterns from TOML
let custom = load_patterns_from_file(Path::new("my_patterns.toml"))?;
API reference¶
CommandOutcome (from executor::run_command)¶
CommandOutcome {
command: String, // Reconstructed command string
exit_code: i32, // 0 on success, -1 on internal error
stdout: String, // Captured stdout
stderr: String, // Captured stderr
elapsed_ms: u64, // Execution time in milliseconds
verified: bool, // true iff exit_code == 0
evidence_type: String, // "command_run" or "command_attest"
stdout_truncated: bool, // stdout was truncated to the output cap
stderr_truncated: bool, // stderr was truncated to the output cap
stdout_total_bytes: u64, // Bytes written to stdout before truncation
stderr_total_bytes: u64, // Bytes written to stderr before truncation
fs_diff: Option<FsDiff>, // Added/modified/removed files (if --watch-dir)
network_activity: Option<NetworkActivity>, // New TCP connections (Linux only)
blake3_signature: Option<String>, // Keyed BLAKE3 MAC
}
SecureExecutionResult (from SecureShell::run)¶
SecureExecutionResult {
command: String, // Original command string
exit_code: i32, // -1 for parse/exec errors
stdout: String,
stderr: String,
verified: bool, // Independent verification success
verification_result: Option<VerificationResult>, // Structured evidence (if verified)
verification_error: Option<String>, // Error description (if not verified)
blake3_signature: Option<String>, // Keyed BLAKE3 MAC
}
FsDiff (from fs_monitor)¶
FsDiff {
added: Vec<String>, // Files created during execution
modified: Vec<String>, // Files changed (SHA-256 hash differs)
removed: Vec<String>, // Files deleted during execution
}
NetworkActivity (from net_monitor)¶
NetworkActivity {
new_connections: Vec<NetworkConnection>, // TCP connections opened during execution
outbound_count: usize, // Total new outbound connections
}
NetworkConnection {
local_addr: String, // e.g., "192.168.1.5:54321"
remote_addr: String, // e.g., "140.82.121.4:443"
state: String, // TCP state: ESTABLISHED, SYN_SENT, etc.
}
HypothesisResult (from evaluate_hypothesis)¶
HypothesisResult {
hypothesis: String, // Agent's stated belief
proposed_command: String, // The command being gated
action_blocked: bool, // Should the action be blocked?
required_proof_steps: Vec<String>, // Steps to complete first
retry_prompt: Option<String>, // Numbered guidance for the agent
cost_estimate_secs: Option<u64>, // Estimated execution time saved
detected_pattern: Option<String>, // e.g., "cargo-build-release"
}
OtRunInput (stdin for attest-ot-run)¶
OtRunInput {
simulator_image_hash: String, // Must match --simulator-image-hash CLI arg
agent_code_hash: String, // Must match --agent-code-hash CLI arg
modbus_blocks_count: u64, // Must match --modbus-blocks-count CLI arg
iec62443_sl_target: String, // Must match --iec62443-sl-target CLI arg
outcome: String, // "success" | "ebpf_blocked" | "crash"
sandbox_id: String, // Unique sandbox identifier
inspection_interface: String, // "lo" for V1 (OTSandboxBackend), or a
// dynamically generated tap device name
// for V2 (FirecrackerOTBackend) of the
// form "tap-ot-<sandbox-id-prefix>"
// (e.g. "tap-ot-a1b2c3d4"). There is
// no fixed "tap0".
raw_log: Option<String>, // Optional captured test log
}
OtRunOutcome (evidence record for attest-ot-run)¶
OtRunOutcome {
simulator_image_hash: String, // From CLI arg (authoritative)
agent_code_hash: String, // From CLI arg (authoritative)
modbus_blocks_count: u64, // From CLI arg (authoritative)
iec62443_sl_target: String, // From CLI arg (authoritative)
outcome: String, // "success" | "ebpf_blocked" | "crash"
sandbox_id: String, // Unique sandbox identifier
inspection_interface: String, // Network interface used for eBPF TC filter.
// V1 = "lo"; V2 = per-sandbox dynamic tap
// device "tap-ot-<sandbox-id-prefix>"
// (created by FirecrackerOTBackend at
// execute() time — never a fixed "tap0").
raw_log: Option<String>, // Optional captured test log
timestamp: String, // ISO 8601 timestamp
evidence_type: String, // Always "ot_simulation_run"
overall_ok: bool, // true iff outcome == "success"
}
Error types¶
enum EvidenceError {
Fabrication(String), // Agent's claim contradicts reality
VerificationFailed(String), // Technical error prevented verification
Io(std::io::Error), // File/system I/O error
Other(String), // Generic error
}
Fabrication maps to exit code 1; all others map to exit code 2.
Configuration reference¶
| Variable | Required | Description |
|---|---|---|
LITATOLI_SIGNING_KEY |
Yes¹ | 64-hex-char (32-byte) BLAKE3 MAC key |
LITATOLI_KEY_FILE |
No | Path to a raw 32-byte BLAKE3 key file (alternative to the env var) |
LITATOLI_ED25519_KEY |
Recommended² | 128-hex-char (64-byte) Ed25519 keypair — primary, non-repudiable signature |
LITATOLI_ED25519_KEY_FILE |
No | Path to a raw 64-byte Ed25519 key file (alternative to the env var) |
ENVIRONMENT |
No | production/staging enable the startup signing-key guard (fail fast, exit 2, if no key is set) |
¹ Required unless a key file is present. There is no auto-generation — an unconfigured BLAKE3 key now errors instead of silently minting a per-host key.
² Not auto-generated. Create one with litatoli keygen (it also writes the
BLAKE3 MAC key), or set it explicitly for a stable, shareable audit key (and to
satisfy the production startup guard). With no Ed25519 key configured, logs are
signed with the BLAKE3 MAC only.
Default BLAKE3 key location: ~/.config/litatoli/signing.key (raw 32 bytes, mode 0600) — read if present, but never created automatically.
Key ID: First 16 hex chars of BLAKE3(key). Included in every evidence entry so verifiers know which key to use.
Security model¶
What Litatoli verifies¶
| Claim type | How it's verified | Trust basis |
|---|---|---|
| Command ran successfully | Exit code + keyed BLAKE3 signature | Process execution observed directly |
| File exists | stat() + SHA-256 hash |
Filesystem read |
| Git commit exists | git show --no-patch |
Local git object database |
| Git push reached remote | git ls-remote |
Network query to remote |
| DB state matches | psql/sqlite3 + SHA-256 of output |
Database query execution |
| Hypothesis has proof | Chain-validated evidence log search | Cryptographic chain integrity |
| OT simulation run | CLI vs stdin cross-check of image hash, agent hash, Modbus blocks, SL target | Mismatch = fabrication (exit 1); signed record uses CLI values |
What Litatoli does NOT verify¶
- Whether the command did what the agent intended (only exit code and side effects)
- Whether git commits contain correct code (only that commits exist)
- Whether the agent's interpretation of output is accurate
- That a log is complete. See below — this one decides how much a green
verify-chainis worth.
The completeness gap, and the two things that close it¶
Cut entries off the end of a log and every remaining check still passes.
seq still runs 1..n, every prev_hash still matches its predecessor, every
signature is still genuine — because they are genuine. Nothing was forged;
something was removed, and removal leaves no trace inside the file. Checkpoints
do not help, since the same cut takes them too.
Deletion in the middle is different: it breaks both seq and prev_hash,
and verify-chain rejects it.
So the answer has to come from outside the log. Two ways, and you want at least one:
- Pin the head. Record the last entry's
blake3_signaturewhen you close a log —verify-chainprints it ashead— and pass it back as--expected-head. Any cut then fails. Cheap, and enough when you control the record. - Use a witness. Submit each checkpoint head to a freshness witness
(
witness-submit). A witness only ever moves a workload forward, so a rolled-back or truncated log reports a head behind what the witness already saw and is rejected. This is also what gives an auditor a signature from something other than the tool under review.
verify-chain says which of these you got. Read the proves field rather than
overall_ok: without a pinned head it states plainly that completeness was not
established, and it warns on stderr.
Shell injection prevention¶
The secure-run command tokenizes input with a POSIX-compliant lexer (shell_split). Shell metacharacters are treated as literal data:
# These are SAFE — semicolons, pipes, backticks are literal tokens:
litatoli secure-run "echo safe; rm -rf /" # "safe;" is a literal argument
litatoli secure-run "echo data | cat /etc/passwd" # "|" is a literal argument
# This is DANGEROUS — only use for trusted commands:
litatoli secure-run --unsafe-shell "echo hello | wc -w" # passes to /bin/sh -c
Credential safety¶
- DB connection strings are redacted in all output (credentials stripped)
- Evidence log entries contain hashes, not raw file contents
- Signing keys are stored with mode
0600and never logged
Limitations¶
- Linux-only for network monitoring —
/proc/net/tcp{,6}parsing requires Linux. On other platforms,network_activityisNone - TCP connection tracking only — UDP, ICMP, and Unix sockets are not monitored
- FS monitoring is snapshot-based —
--watch-dirtakes SHA-256 snapshots before/after execution; rapid intermediate changes are not captured - Symlinks skipped in FS monitor — prevents directory traversal loops but means symlinked files are not tracked
- Git verification requires local repo —
verify-git-commitandverify-git-pushneed the git repo on disk - DB verification shells out — requires
psqlorsqlite3binaries on PATH - Hypothesis matching is substring-based —
proposed_commandis matched against patterns via case-insensitive substring match, not AST parsing - Evidence log is single-writer — concurrent appends from multiple processes to the same log file may corrupt the chain
Troubleshooting¶
Exit code 2 on --log-file¶
Both log-open and log-append failures produce exit code 2 with a structured error:
{
"overall_ok": false,
"specs": [{ "id": "error", "ok": false, "errors": ["Failed to open evidence log '/path': Permission denied"] }]
}
Fix: Check file permissions, parent directory existence, and disk space.
Fabrication detected: commit not found¶
The git commit hash doesn't exist in the local repository.
Fix: Ensure you're running in the correct git repo (--cwd), and that the commit has been fetched locally.
Could not parse remote/branch from command¶
The secure-run git push verifier couldn't extract remote and branch from the command.
Fix: Ensure the command includes both remote and branch: git push origin main. Flags in any position are supported.
Hypothesis blocks my command¶
The command matches a costly action pattern and no proof exists in the evidence log.
Fix: Run a lighter-weight proof command first and log it:
litatoli run --log-file evidence.jsonl cargo check
# Now this will be allowed:
litatoli evaluate-hypothesis --hypothesis "Code compiles" --proposed-command "cargo build" --log-file evidence.jsonl
Key provisioning¶
There is no auto-generation of the BLAKE3 MAC key — provision it explicitly:
# Generate and export a stable 32-byte BLAKE3 key (share it across machines)
export LITATOLI_SIGNING_KEY="$(python3 -c 'import os; print(os.urandom(32).hex())')"
# Optional: a stable Ed25519 keypair for non-repudiable, publicly verifiable
# signatures. It is NOT auto-generated — create one with `litatoli keygen`
# (or set LITATOLI_ED25519_KEY). With no Ed25519 key, logs carry only the
# BLAKE3 MAC.
export LITATOLI_ED25519_KEY="<128 hex chars>"
In production (ENVIRONMENT=production/staging) the CLI refuses to start
(exit 2) unless at least one signing key is configured.
Integration with AI agent frameworks¶
Litatoli is a standalone binary — plug it into any agent framework:
import subprocess, json
def verify_agent_claim(message: str) -> dict:
result = subprocess.run(
["litatoli", "evaluate-message", message],
capture_output=True, text=True
)
return json.loads(result.stdout)
# In your agent loop:
agent_output = agent.run("Fix the bug and push to main")
verification = verify_agent_claim(agent_output)
if not verification["overall_ok"]:
print("Agent claim not verified — rolling back")
Related packages¶
| Package | Purpose |
|---|---|
nazelo |
Sandbox engine — 4-tier isolation with BLAKE3 capability tokens and eBPF egress |
nazelo-critical |
Multi-domain critical-systems sandbox plugin — Ghost PLC, Ghost Chip, serial, CAN, Firecracker backends covering ICS/OT, aerospace, automotive, medical, rail, maritime, energy, and identity |
pimatika |
In-process governance enforcer — judges egress against a signed Build Intent Manifest. Writes its decisions to an Litatoli-format evidence log via LitatoliEvidenceWriter (depends on litatoli-core) |
jagora-critical |
Multi-domain critical-systems orchestration — blueprints, TRIZ, framework compliance assessment for the 9 critical domains |
| Jagora AIDE | Full orchestrator — code generation, compliance, TalaSala developer HUD |
Integration with Jagora AIDE¶
Litatoli is the evidence engine used natively by Jagora AIDE:
EvidenceVerifierwraps the Litatoli binary for Python-native access- Agent contracts (pre/post checks) route through Litatoli for verification
- TalaSala HUD displays verification status (Trust badge) in the Control Room
- Compliance reports (EU AI Act, IEC 62443, ISO 27001) consume evidence entries
- Hypothesis gate integrated into the autonomous orchestrator
- SecureShell delegates to Litatoli's
secure-runto execute without a shell - OT attestation (
attest-ot-run) signsnazelo-criticalsandbox outcomes for auditors
Jagora AIDE layers orchestration, compliance interpretation, developer HUD, and multi-agent governance on top.
Positioning¶
NaZelo = "I don't trust the code" (sandboxed execution)
Litatoli = "I don't trust what the agent claims" (evidence verification)
nazelo-critical = "I don't trust the critical-systems environment" (multi-domain industrial protocol isolation)
pimatika = "I don't trust runtime drift from build intent" (in-process governance enforcement, evidence emission)
Jagora = "I control all of these, in a complete workflow"
Roadmap¶
- Sigstore/in-toto —
export-attestationsships in-toto v1 / DSSE export today, andwitness-submit --witness rekor:anchors each checkpoint head to a public Sigstore transparency log for the freshness tier. Emitting SLSA provenance at seal time remains on the roadmap - Custom evidence backends — Pluggable
EvidenceRecorderimplementations for SIEM/compliance platforms - Async network monitoring — Real-time connection tracking instead of snapshot-based diffs
- Extended DB support — Native client libraries instead of shelling out to
psql/sqlite3
Plans & pricing¶
See litatoli.dev for current tiers, quotas, and features. litatoli-core and litatoli-cli are free OSS; the managed evidence aggregation service (litatoli-saas) is a paid tier.