# Occasio — Audit Log Format and Independent Verification **Audience.** Security / compliance reviewers and platform engineers who need to verify Occasio's audit trail without trusting Occasio's own verifier. **Promise.** Every governed tool call writes one row to `~/.occasio/pipeline-events.jsonl`. Each row is hash-chained to the previous row using SHA-256, starting from a fixed genesis sentinel. Any post-hoc edit, reordering, or deletion within the chain is detectable by re-walking the file. This document specifies the row format and the canonical-serialization rules precisely enough that an independent walker (a small Python script, included) reproduces the verification end-to-end. --- ## 1. Row format Each line of `pipeline-events.jsonl` is a UTF-8 JSON object. A row is built in **this exact field order**: ``` ts, event_id, session_id, run_id, agent, protocol, direction, kind, tool_name, tool_inputs, action, reason, policy_source, executor, transform, result_kind, exit_code, secrets_redacted, distilled, tokens_saved, prev_hash, hash ``` Field semantics: | Field | Type | Notes | |---|---|---| | `ts` | string (ISO-8601) | Event timestamp from the boundary event. | | `event_id` | string (UUID) | Unique per event. | | `session_id` | string | Stable per Occasio session. | | `run_id` | string | Stable per agent run. | | `agent` | string | Canonical agent id (e.g. `claude-code`). | | `protocol` | string | Wire protocol (e.g. `anthropic-http`). | | `direction` | string | `inbound` (agent → cloud) or `outbound`. | | `kind` | string | `tool_call`, `request`, etc. | | `tool_name` | string | Canonical tool name (e.g. `read_file`). | | `tool_inputs` | object \| absent | Normalized inputs (see `src/audit/input-normalizer.js`). Absent means the tool's inputs are intentionally not logged. | | `action` | string | `LOCAL`, `PASS`, `BLOCK`, or `TRANSFORM`. | | `reason` | string | Reason code from the policy engine. | | `policy_source` | string | `default` or `user`. | | `executor` | string \| absent | Where the action ran (e.g. `native`). | | `transform` | string \| absent | Transform applied, if any. | | `result_kind` | string | `local`, `pass`, `block`, `transform`, or `unknown`. | | `exit_code` | number \| absent | Non-zero on local execution failure. | | `secrets_redacted` | number \| absent | Count of secrets redacted in the result. | | `distilled` | bool \| absent | Whether output was distilled. | | `tokens_saved` | number \| absent | Tokens saved by distillation. | | `prev_hash` | string (64-hex) | Hash of the previous row, or genesis on the first row. | | `hash` | string (64-hex) | SHA-256 of the row's canonical serialization with `hash` removed. | Fields whose value would be `undefined` (in JS) or `None` (in Python) are **omitted** from the serialized row, not emitted with a null value. This matches V8's `JSON.stringify` default behavior. ### Row kinds `kind` distinguishes what an audit row records. There are five: | `kind` | When it fires | Semantics | |---|---|---| | `tool_call` | Every governed tool call (Claude Code or MCP) | `tool_inputs` is per-tool (file path, glob, count). `action` is one of `LOCAL`/`PASS`/`BLOCK`/`TRANSFORM`. `result_kind` is `local`/`pass`/`block`/`transform`. | | `request` | Every HTTP request through the proxy (Anthropic SSE or budget-blocked or local-only) | Per-request accounting row: cost, tokens, cache savings, savings breakdown, coverage counters (`tools_attempted`, `tools_local_count`, `tools_mcp_count`). `event_type` is `cloud_sent`, `local_only`, `blocked`, `trimmed`, or `budget_exceeded`. No `action`/`result_kind` (those are tool-call concepts). | | `policy_loaded` | Process startup, and on every policy-file edit (hot-reload) | `tool_inputs` is `{ policy_hash, policy_path, version }`. `tool_name` is the placeholder string `"policy_loaded"`. `action` is `"INFO"`. `reason` is `"policy-loaded"`. **`result_kind` is omitted** because a policy-load event has no dispatcher Result. | | `git_state` | Run start and run end (when launched via the `claude` proxy) | `tool_inputs` is `{ phase, cwd, is_repo, head, branch, dirty, changed_files, untracked_files, diff_hash, digest }`. `phase` is `run_start` or `run_end`. `tool_name` is the placeholder string `"git_state"`. `action` is `"INFO"`. `reason` is `"git-state"`. **`result_kind` is omitted.** Capture is best-effort: a non-git directory or missing git binary records `is_repo:false` rather than aborting the run. | | `limit_exceeded` | A per-round volume cap (`policy.limits`) was hit and the run was halted | `tool_inputs` is `{ limit, max, actual, round, decision:"block" }` where `limit` is the violated key (`max_tool_calls_per_round` / `max_bash_calls_per_round` / `max_bytes_to_model_per_round`). `tool_name` is the placeholder string `"limit_exceeded"`. `action` is `"BLOCK"`. `reason` is `"limit-exceeded"`. **`result_kind` is omitted.** | The `policy_loaded` row binds the audit chain to a specific policy file's bytes: a buyer can prove not just "what was blocked" but "under which exact `policy.yml` the block was decided." Because the hash is over the raw file bytes (not the normalized policy object), comments and whitespace count, so the hash matches whatever a reviewer reads in source control. The `git_state` rows bind a run to the concrete repository state it ran against: the `run_start` row records HEAD + a `diff_hash` before the agent acts, and the `run_end` row records what it left behind (changed/untracked files, post-run `diff_hash`). `occasio attest` lifts these rows into `subject.git_state` (`provenance: "chain"`), and `occasio attest verify` re-derives the same object straight from the hash-protected rows and requires byte-equality — so a tampered git claim in an attestation fails verification. #### Identity-gate enrichment (v2) When the identity gate (`deny_commands` / `identity_approval`) blocks a shell command, the `tool_call` row carries an additional set of **additive** fields describing the identity event. They appear only on identity-gate decisions; ordinary rows are byte-for-byte unchanged, and the fields slot in before `prev_hash` so `hash` stays last and the walker reproduces the serialization without modification. The field names are chosen to be in-toto-predicate compatible, so a future signed identity-delegation predicate is a projection of these rows rather than a migration. | Field | Type | Notes | |---|---|---| | `event_type` | string | One of: `identity_borrow_request` (agent attempted a borrow → blocked pending approval), `identity_borrow_consumed` (a human-approved one-time token was spent → the command was let through), `identity_borrow_approved` / `identity_borrow_denied` (a human decided, from the CLI), `identity_borrow_expired` (an approved token expired unused), `secret_identity_access_blocked` (deny_commands), `control_plane_blocked` (the agent tried to mutate the approval control plane). | | `actor` | object | `{ type, id, session_id, trust_level }` — the AI agent. `type` defaults to `ai_agent`, `trust_level` to `untrusted`. | | `delegator` | object | `{ type, id }` — the human the agent acts on behalf of, from `~/.occasio/identity.json` (fallback: OS username). | | `tool` | object | `{ type, name, raw_command, command_hash }` — `command_hash` is the canonical normalized-command hash (`src/policy/command-normalize.js`), identical to the approval token's hash so a row can be joined to its approval. | | `identity_requested` | object | `{ type, target_class, risk }` — the identity being borrowed and its blast-radius class. | | `classification` | object | `{ action, reason, matched_rule }` — the identity classifier's verdict. | | `policy_decision` | object | `{ decision, reason, policy_name }`. | | `approval` | object \| null | The handshake state: `{ approval_id, state, approved_by, identity_source }`. `state` ∈ `pending` (request), `consumed` (a grant was spent), `approved`/`denied`/`expired` (lifecycle). `approved_by` + `identity_source` (`explicit`/`os_fallback`) name *who* authorized. | | `enforcement_point` | string | `proxy` (decided inside the proxy loop) or `cli` (a human approve/deny/expire, written from the un-proxied terminal). | | `coverage` | string | `enforced` (the command provably did not run), `authorized` (it ran under a valid human token — `identity_borrow_consumed`), or `n/a` (a lifecycle event, not a command decision). | | `decided_by` | string \| absent | `human` for a CLI approve/deny, `policy` for a deny_commands hard-deny — disambiguates a human rejection from a policy block. | The `coverage` field is an honesty guarantee: a `denied`/`request` row with `coverage: enforced` means the command provably did not execute; `authorized` means it ran but under a recorded human approval (the `approval` block names who). The gate never records `enforced` for a path it did not actually intercept. The control plane (`occasio approvals approve|deny`, `occasio identity set`) is itself hard-BLOCKed on the agent path (`control_plane_blocked`), so an agent cannot forge its own approval — a human authorizes out-of-band, from a terminal that is not proxied. Forging the store/identity files directly is mitigated by HMAC-signed tokens + `deny_paths ~/.occasio/**`; an obfuscated-interpreter write is the documented residual (see [identity-gate.md](identity-gate.md)). #### `request` row field order The `request` row uses its own canonical field order. The order is load-bearing for hash stability and `test-audit-chain.js` test #20 locks it in: ``` audit_schema, ts, event_id, session_id, run_id, agent, protocol, direction, kind, event_type, model, cwd, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost, cache_savings, lao_tokens_saved, lao_cost_saved, distill_tokens_saved, distill_cost_saved, tools_attempted, tools_local_count, tools_mcp_count, prev_hash, hash ``` Adding a new accounting field is a chain-schema change. Append it to the end of the order (before `prev_hash`), bump `audit_schema` if the semantics break older verifiers, and update `docs/audit_walker.py` in lockstep so independent verification continues to walk every kind. ## 2. Genesis sentinel The `prev_hash` of the first row in a chain is: ``` 0000000000000000000000000000000000000000000000000000000000000000 ``` (64 zero hex digits.) ## 3. Hash algorithm For each row: 1. Take the row object. 2. Remove the `hash` field. 3. Serialize **exactly as V8's `JSON.stringify` would**: in insertion order, no whitespace between tokens, no key sorting, non-ASCII characters emitted literally, and **numbers formatted by the ECMAScript `Number::toString` rules**. 4. Compute the lowercase hex SHA-256 of the resulting bytes. > **Note — number formatting is part of the canonical form.** Python's > `json.dumps` is **not** byte-equivalent to V8 for all numbers: small floats > differ in decimal-vs-exponential notation (V8 emits `0.00003`, `json.dumps` > emits `3e-05`), and integer-valued floats differ (`30` vs `30.0`). The audit > rows contain sub-`$0.0001` cost/savings floats, so a naïve `json.dumps`-based > verifier will FALSELY reject valid chains. The reference walker > (`audit_walker.py`) therefore reimplements V8's `Number::toString` directly; > `test-audit-xlang.js` pins Node≡Python over an adversarial number/string > battery so the two can never drift. That is the value of `hash`. The `prev_hash` of the next row equals this `hash`. ## 4. Independent walker A standalone Python script, [`audit_walker.py`](audit_walker.py), implements the verification with no Occasio dependencies — only `hashlib`, `json`, `sys` from the standard library. To run it: ```sh python3 docs/audit_walker.py ~/.occasio/pipeline-events.jsonl ``` Expected output for an intact chain: ``` OK: 31 rows verified ``` If any row's `prev_hash` does not match the previous row's `hash`, or any row's recomputed hash does not match its stored `hash`, the script exits non-zero with a `MISMATCH at line N: …` message identifying the first inconsistency. ## 5. Parity with Occasio's own verifier Occasio ships its own verifier (`occasio audit verify`). For audit credibility, both must agree on the same file. Parity is checked at every release; v0.6.4 is verified to agree on the maintainer's 31-row reference log. If you find a row where `audit_walker.py` and `occasio audit verify` disagree, that is a bug. Open an issue with the row line number and we will treat it as audit-credibility-critical (i.e. fix-before-next-release). ## 6. What this proves and does not prove **Proves.** No row in the chain has been edited after the fact. No row has been removed from the middle of the chain. No row has been reordered. **Does not prove.** - That no rows were *omitted* — i.e. that the proxy was running and recording during every session in which it should have been. Gaps in time are visible in the `ts` field, but proving "no governed action escaped the log" requires comparing the audit log against an external record of agent activity. For pilots that need this guarantee, ship the audit rows offsite (SIEM, S3, append-only file) on a tail cadence. - ~~That the proxy was running with the policy file you expected.~~ **(Resolved in v0.6.6.)** Every process startup and every hot-reload appends a `policy_loaded` row carrying the SHA-256 of the active policy file's bytes; subsequent tool-call rows are bound to the most recent `policy_loaded` row by chain position. To verify "this BLOCK happened under this exact policy file": (1) find the BLOCK row, (2) walk backward to the most recent `policy_loaded` row, (3) compare its `tool_inputs.policy_hash` to a SHA-256 of the file you intend to compare against. The walker in `audit_walker.py` will accept both `kind` values without modification. - That **multiple processes** writing to the same audit file did not interleave. The Claude Code proxy and the MCP server each emit their own `policy_loaded` rows, which is correct, but they share `pipeline-events.jsonl` under a single-writer assumption documented in v0.6.5's CHANGELOG. Concurrent writers on Windows can interleave; the chain detects the corruption but cannot repair it. - That a row written *during* a write outage was not lost. v0.6.4 aborts the proxy with exit code 1 when an audit append fails, so a successful tool dispatch cannot coexist with a missing row in steady state. The combination of (a) fail-fatal audit writes and (b) a supervisor that restarts the proxy is the operational guarantee. ## 7. Stability commitment The audit row schema and field-order list in §1 are part of Occasio's stable surface. They will not change incompatibly across v0.6.x. Any future field will be added in a way that does not invalidate existing rows or re-walks of the chain. `audit_walker.py` in this repository is the canonical reference. If your verifier produces different bytes on the same input, your verifier is wrong, not the spec.