diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 9129759..668a28e 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@ @@ -3020,6 +3020,8 @@ rows that carry an id, and keep listing the pre-2026-09-07 rows unattributed. makes the same stale claim. Both are non-core and are left for their own change so this one stays reviewable. +**Amended 2026-11-05.** `docs/receipt-coverage.md` no longer makes the stale claim named above; that half is closed. `src/views/since.js` still prints it, and that half stays open. + ## 75. One edited file was listed twice in `touched`, once absolute and once relative **Status: CLOSED 2026-10-19, branch `lotor-lane/touched-path-identity`.** @@ -3352,3 +3354,41 @@ so a chain with an inserted garbage line can pass `store.verify()` and still refuse to seal; and re-sealing from scratch yields byte-identical notes (Ed25519 is deterministic and the note text is fixed by size and root), so losing the sidecar loses nothing a stranger kept. + +## 87. A session receipt holds network targets in plaintext, including the start of Bash commands + +Found 2026-11-05 re-deriving `docs/receipt-coverage.md`; the doc had never +listed this field as readable text. Every session receipt carries a `sent` +array, and `src/parser/index.js` fills it through two functions, +`isNetworkTool` and `extractTarget`. A WebFetch call puts its full URL in +`sent.items[].target`, a WebSearch call puts its full query there, and a +Bash command puts the first 100 characters of the command there. Nothing +redacts any of it, and ingest passes the field through to the chain +unchanged. + +The Bash test is a bare substring over network verbs, so it fires wider than +the name suggests. The verb list is matched with `includes` against the +lowercased command, not parsed as a command line, so `mapping` qualifies +through the `ping` it contains and a filename containing `scp` qualifies. +A command with no network verb anywhere in its text does not. + +The consequence is that readable text lands on the chain: a credential in a +`curl` header, an API key in a fetched URL's query string, or a private +search query arrives in the receipt exactly as typed. The chain is +append-only and hash-linked, so removing the row later breaks verification, +and `npm run export` carries it off the machine with the rest of the chain. + +Limit 18 discloses authorised command strings in plaintext on grant rows. +This is a different writer: the parser puts the target on the chain as part +of every session receipt, and it needs no grant to do it. + +`test/known-limits-87-sent-plaintext.test.js` is a tripwire over a synthetic +transcript with fake values. It pins today's behaviour; it is not an +endorsement of it. + +Not measured on a real chain, on purpose. Looking would mean searching the +operator's own record for secrets, which is the operator's call. + +Not fixed. Digesting the target would remove the only readable account of +where a session reached out, so this is a trade for the owner to make, not +a one-line fix. diff --git a/docs/receipt-coverage.md b/docs/receipt-coverage.md index 4ac111e..a270289 100644 --- a/docs/receipt-coverage.md +++ b/docs/receipt-coverage.md @@ -1,169 +1,419 @@ # What the chain records, and what it does not **Written 2026-07-26 by reading the writers, not by reading notes about them.** -Every claim below cites the file that produces the thing described. Where a -claim could not be grounded in source it says so instead of guessing. +**Re-derived 2026-11-05 against commit 22ae903 by reading every append site.** +Every claim below cites the writer's file and the function or `type:` literal +that produces it, never a line number. Line numbers drift, and citing them is +how this file went stale between 2026-07-26 and 2026-11-05. Where a claim +could not be grounded in source it says so instead of guessing. This document exists because counting is the product. A counter whose coverage nobody has enumerated is a counter making promises it has not checked. --- -## The six shapes +## The shapes -Six kinds of entry share one chain and **they do not share a shape**. Two -different fields discriminate them, which is the first finding below. +Nine kinds of entry share one chain and **they do not share a shape**. Two +different fields discriminate them, which is stated below. | Entry | Discriminated by | Written in | |---|---|---| -| session receipt | *(neither)* — carries `session` | `src/parser/index.js` | -| `session-open` | `type` | `bin/hook-session-start.js` | -| `gated-action` | `type` | `src/gate/index.js` | -| `policy-warn` | `type` | `bin/hook-pre-tool-use.js`, `bin/hook-post-tool-use.js` | -| `egress-event` | `type` | `bin/hook-post-tool-use.js` | +| session receipt | *(neither)*: carries `session`, no `type` | constructed in `src/parser/index.js` (`parseSession`), appended and enriched in `src/ingest/index.js` (`ingestSession`) | +| `session-open` | `type` | `bin/hook-session-start.js`, in its `appendReceiptGuarded` callback | +| `gated-action` | `type` | `src/gate/index.js` (`gatedAction`), called from `bin/hook-pre-tool-use.js`, the `bin/gate.js` CLI, and the `gated_action` MCP tool in `src/mcp/server.js` | +| `policy-warn` | `type` | `bin/hook-pre-tool-use.js` (six construction sites), `bin/hook-post-tool-use.js` (one) | +| `egress-event` | `type` | `bin/hook-post-tool-use.js` (`main`) | +| `grant-use` | `type` | `bin/hook-pre-tool-use.js`, the grant path of the gate branch | +| `delegation-grant` | `type` | `src/grant/issue.js` (`recordGrantOnChain`); the type is the `GRANT_TYPE` constant in `src/grant/grant-schema.js` | +| `reasoning-step` | `type` | `src/reasoning/index.js` (`captureStep`, appended by `recordReasoningStep`), triggered from `bin/hook-post-tool-use.js` (`main`) on every tool call | | `ledger-head` | **`kind`** | **no writer in this repository** | +Two naming conventions live in one chain: camelCase payloads under `type`, +and the attempt-ledger's snake_case payloads under `kind` (read together in +`src/mcp/server.js`, `summarizeEntry`). Any reader checking only `type` +labels the `ledger-head` rows `unknown`. + --- ## Field by field -### Session receipt — `src/parser/index.js:186,190` +### Session receipt, from `src/parser/index.js` (`parseSession`), appended by `src/ingest/index.js` (`ingestSession`) The original shape. **It carries no `type` field at all**, because it predates -typed payloads. It is identified by the presence of `session`. - -- `session` — id, model, subsession -- `counts` — `turns`, `toolCalls`, `failures`, `transcriptEntries`, - `assistantMessages` -- `touched` — array of `{ path, ...meta }`, so this is the **only** entry type - that records paths - -This is the only entry that answers "what did that session do". - -### `session-open` — `bin/hook-session-start.js:216` - -- `sessionId`, `openIndex`, `source`, `cwd`, `transcriptPath` -- `chainHeadAtOpen`, `chainLengthAtOpen`, `verifiedAtOpen` -- `policy` — version, mode, modes, digest - -**`sessionId` sits at payload level, deliberately not nested under `session`.** -The source comment says why: the view layer treats any payload carrying -`session` as a full session receipt, and an open is not one. A reader that only -looks under `session.id` drops it silently, which is what `query_receipts` did -until this pass. - -### `gated-action` — `src/gate/index.js:149, 167, 192, 209` - -Four call sites, three denials and one approval. - -- denied, no token: `decision`, `action`, `reason`, `timestamp` -- denied, invalid token: same -- denied, nonce replay: same -- **approved**: `decision`, `action`, `approvalNonce`, `timestamp` - -**It records that a signature was spent and on which tool. It does not record -what was approved.** No path, no command, no params, no request digest. This is -the hardest limit in the file and it is load-bearing: a signature cannot be -matched to a charter item after the fact, so per-signature attribution is not -merely unbuilt, it is not derivable. `src/views/autograph.js` attributes by -window for exactly this reason. - -### `policy-warn` — `bin/hook-pre-tool-use.js:626,643,688,712,731,895` and `bin/hook-post-tool-use.js:249` - -- `ruleId`, `tool`, `paramsDigest`, `timestamp` - -A rule that warned rather than gated. `paramsDigest` is a hash, so the params -are provably identical or provably different and never readable. - -### `egress-event` — `bin/hook-post-tool-use.js:259` - -- `ruleId`, `tool`, `paramsDigest`, `responseDigest`, `responseOk`, `timestamp` - -The only entry that records anything about a **response**, and only as a digest -plus a boolean. - -**Staged, not yet on the chain (v3 V3-03, 2026-10-22):** a `toolIo` field -beside these, built by `src/toolio/index.js`: `schema: 'toolio/1'`, a `hash` -code stamp, and either `{ recorded: true, digest, bytes, error }` or -`{ recorded: false, reason }`. Where `responseOk` and `toolIo.response.error` -disagree (`error: false`, `''` or `0` on the response), `toolIo` is -authoritative. -The hook wiring waits for a signing sitting -(`proposals/v3-03-tool-io-digest-2026-10-22.md`); until it lands, no receipt -carries the field. KNOWN-LIMITS 78 says what it still does not cover. - -### `ledger-head` — **no writer in this repository** - -Observed live in the chain at seq 727 and 728. Fields: `limit_id`, -`limit_text_sha256`, `entry_count`, `head_hash`. - -**Two things about it are worth stating plainly.** +typed payloads. It is identified by the presence of `session`. Ingest decides +`subsession` and runs the no-change guard inside `appendReceiptGuarded`, so +two concurrent firings of the same session cannot both append subsession 0 or +duplicate a no-change append. One chain entry answers "what did that session do". + +- `session`: `id`, `model`, `version`, `startedAt`, `endedAt`. + `subsession` (0, 1, 2, ...) is added by ingest. Each qualifying SessionEnd + appends a new receipt subsession when the transcript has grown; no growth, + no append. +- `counts`: `turns`, `toolCalls`, `failures`, `transcriptEntries`, + `assistantMessages`. +- `ran`: array of `{ tool, id, paramsDigest, paramsDigestCanonical }`. + `paramsDigest` is the 16-hex parser dedup digest; + `paramsDigestCanonical` is the full 64-hex digest over the params/1 + canonicalisation. The presence of the second field marks receipts written + after the Observa interop seam was wired; it may carry + `correlationIdEcho` when the harness planted `_observaCorrelationId` in + the tool input (echoed verbatim, bounded, never interpreted). +- `touched`: array of `{ path, via, alsoVia?, outcome? }`, the structured + record of files a session changed. `outcome` exists only for + Edit/Write attempts settled by a tool_result: `ok`, `error`, or `unknown` + when the session died mid-turn. Entries that came only from file-history + carry no `outcome` key. +- `failed`: array of `{ tool, id, errorDigest }`. +- `cost`: `inputTokens`, `outputTokens`, `cacheCreationTokens`, + `cacheReadTokens`, `byModel`, and `schema: 'cost/4'`. `byModel` is a + per-model breakdown keyed by the model string the runtime wrote, one + bucket of `inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, + messages` per model; a message with no model lands in the `"unknown"` + bucket rather than being dropped, and no sum across buckets is produced. + The parser's own `note` field says so: **tokens only; no USD in source**. + The parser's `cost.thoughts` array is replaced by ingest, before the + append, with a binding `{ schema: 'thoughts/1', count, digest }` over a + sidecar file, so the receipt stays slim. +- `sent`: `{ items: [{ tool, target }], captureNote }`, filled by + `isNetworkTool` and `extractTarget` in `src/parser/index.js`. The + `target` is PLAINTEXT on the chain: the full URL for a WebFetch call, the + full query for a WebSearch call, and the first 100 characters of a Bash + command. A Bash command qualifies when its lowercased text contains any + network verb as a bare substring (`curl`, `wget`, `git push`, `git pull`, + `git fetch`, `npm publish`, `npm install`, `npx`, `ssh`, `scp`, `rsync`, + `ping`, `telnet`, `nc `, `netcat`), so `mapping` qualifies through `ping`. + Nothing redacts the target (KNOWN-LIMITS 87). The note says the capture is + self-attested and outbound is not fully derivable from JSONL. +- `receiptSchema: 'receipt/2'`. +- `transcriptHash`: full 64-char SHA-256 over the exact transcript bytes the + receipt summarizes. It proves what was hashed, not that what was hashed + was the whole session. +- Added by ingest, before the append: + - `chapters`: `{ schema: 'chapters/1', count, digest }`, a digest binding + over chapter titles derived from the same text the receipt summarizes. + Titles are stripped before hashing, so operator words never enter the + chain. + - `reasoning`: the reconciliation between the `reasoning-step` receipts on + the chain and the transcript. When binding fails it carries + `{ schema: 'reasoning-trace/1', error }`. Its normal-success shape was + not fully chased in this pass (see the audit note at the bottom). + - `reach`: the close half of the reach certificate + (`src/reach/certificate.js`, `measureCloseCertificate`), schema + `reach/1`, model and runtime each with the basis they were measured on, + a per-rung map over `R0` through `R6`, `deepestRecorded`, + `deepestObserved`, and an open-half link taken from the chain inside the + lock. A measurement failure is a distinct `measurement-failed` state, so + a failed rung never reads as "measured and found nothing". + - `subagents`: present only when the caller passed `transcriptPath` and a + `/subagents/` directory of child transcripts sits beside it + (`src/ingest/subagents.js`). Omitted entirely otherwise, never a false + zero. Its inner field shape was not fully chased in this pass. + +### `session-open`, from `bin/hook-session-start.js` (within its `appendReceiptGuarded` callback) + +- `toolPins`, `toolDefs`, `toolPinDiff`, `toolPinDiffSummary`, + `toolPinSchemaVersion` (`tp/1`), from `buildToolPins`: fingerprints of + every tool definition the harness exposed at open, a diff against the most + recent previous open that carried pins, and (when the harness gave a + listing) `toolDefs` holds the **full tool definitions in plaintext**. When + the harness exposed no listing, the pin fields are null and the summary + says so. +- `sessionId`: sits at payload level, deliberately not nested under + `session`. The source comment says why: the view layer treats any payload + carrying `session` as a full session receipt, and an open is not one. A + reader that only looks under `session.id` drops it silently. +- `openIndex`: counted under the chain lock; SessionStart fires again on + resume, clear and compact, and each firing gets its own entry. +- `source`, `cwd` (null when the payload carried none), `transcriptPath`. +- `chainHeadAtOpen` (`{ seq, hash }` or null), `chainLengthAtOpen`, + `verifiedAtOpen` (`{ ok, reason, brokenAt? }` from verifying the chain as + loaded, against the chain public key). +- `policy`: `{ version, mode, modes, digest }` of the effective policy, or + null when the policy could not be loaded (the open is still recorded). +- `hooks`: the hook-registration snapshot (`src/registration.js`, + `snapshotHookRegistration`): `readable`, `sessionStart`, `preToolUse`, + `postToolUse`, `sessionEnd`, `sourcesRead`. A settings file that could not + be read records `readable: false` instead of a false "not registered". +- `observer`: what the witness itself was capable of: `schema: 'observer/2'`, + `packageVersion`, `matcher` and `parser` stamps (`{ schema, hash }`), and + `build`, the whole-tree source digest. Absence on an older entry means the + chain predates instrument versioning, not an error. +- `harness`: which harness wrote this and on what basis (`declared`, + `inferred`, `unknown`). Never a bare name: an unknown harness stays + unknown rather than defaulting to the common case. +- `reach`: open half of the certificate (`src/reach/certificate.js`, + `buildOpenCertificate`): model and runtime as the harness declared them, + each with its basis, rungs left `not-yet-measured`. Nothing has been + emitted at open, so any rung stated there would be a guess. +- `lotorVersion: 1`, `timestamp`. + +### `gated-action`, from `src/gate/index.js` (`gatedAction`) + +Four construction sites inside the one function: three denials and one +approval, appended through the `chain.append` interface the caller passes. + +- common to all four: `type`, `decision` (`approved`, `denied`, or + `stale_signature` when verification failed because the token's timestamp is + stale or in the future), `action` + (the tool name), `ruleId` (null when the caller had no rule), `sessionId` + (null when the caller had none), `agentId`/`agentType` **only when the + caller's meta carries an agent id** (a call made inside a subagent), + `paramsDigestCanonical`, `heldMs` (milliseconds between staging and + decision, or null), `matcherHash`, `timestamp`. +- each denial adds `reason` (`no approval token provided`, the verification + failure, or `approval token nonce already used (replay detected)`). +- the approval adds `approvalNonce` (the nonce that was verified and spent). + +**`paramsDigestCanonical` is a digest, never the params.** It is a full +64-hex SHA-256 over the request's params under the params/1 canonicalisation +(`src/parser/index.js`, `digestParamsCanonical`). The hook caller +(`bin/hook-pre-tool-use.js`) signs only the subset of params that carry +security meaning (`command`, `file_path`, `url`, `path`), so this digest +covers exactly that subset; the `gated_action` MCP tool and the `bin/gate.js` +CLI pass whatever params the caller supplied and digest all of them. + +**It records that a signature was spent, on which tool, against the digest of +the signed params, and whether it was denied or approved.** What it does not +record: the params themselves, the agent's stated purpose, anything about the +response. The digest proves a request was identical or different and tells +you nothing about either. Per-signature attribution is derivable only by +candidate matching (`src/views/autograph.js` attributes by charter window, +with digest attribution computed against candidates, for exactly this +reason). + +Callers that pass no meta (`bin/gate.js` CLI, the `gated_action` MCP tool) +produce null `ruleId`, `sessionId`, `heldMs` and no agent fields on the row. + +### `policy-warn`, from `bin/hook-pre-tool-use.js` (six sites) and `bin/hook-post-tool-use.js` (one) + +Not one shape but four, all `type: 'policy-warn'`: + +- `ruleId: 'engine-error'` (three sites in the pre hook, one in the post + hook, same keys): `ruleId`, `tool`, `paramsDigest` (16-hex), + `matcherHash`, `timestamp`. **No session id and no agent fields.** This is + the engine reporting that the engine itself is degraded; it has no session + binding to offer. +- warn-mode rule hit (one site): `ruleId`, `tool`, `agentId`/`agentType` + (subagent calls only), `paramsDigest`, `matcherHash`, `timestamp`. + **No session id.** +- `ruleId: 'both-layers-permissive'` (one site, at most once per session, + deduplicated by a chain read): `ruleId`, `sessionId`, + `agentId`/`agentType` (subagent calls only), `tool`, `lotorMode`, + `harnessMode`, `matcherHash`, `timestamp`. **No `paramsDigest`**: it warns + about posture, not a specific call. +- unknown mode treated as warn (one site): `ruleId`, `tool`, `paramsDigest`, + `matcherHash`, `timestamp`. **No session id and no agent fields.** + +`paramsDigest` is a hash of the JSON tool input, so the params are provably +identical or provably different and never readable. + +### `egress-event`, from `bin/hook-post-tool-use.js` (`main`) + +Written only for a Bash call that matched an egress rule +(`push-force`, `push-protected`, `publish`, `egress-other`; `matchEgress` +returns null for every other tool). Anything else writes no egress row, but +the `reasoning-step` row for that call was already written before this check. + +- `ruleId`, `tool`, `sessionId` (null when the hook payload carried none), + `agentId`/`agentType` (a subagent's call only), `paramsDigest` (16-hex), + `responseDigest` (16-hex over the JSON of the response, `empty` when there + was none), `responseOk` (boolean, or null when no success/error signal + was recognizable), `toolIo`, `matcherHash`, `timestamp`. + +`toolIo` is real, on the chain, not staged: `src/toolio/index.js` +(`describeToolResponse`) builds `{ schema: 'toolio/1', hash, response }` +where `hash` names the code that ran it and `response` is either +`{ recorded: true, digest, bytes }` plus the host's own error flag, or +`{ recorded: false, reason: 'absent' | 'null' | 'unserializable' }`. An empty +string or empty object **is** recorded; unrecorded is never an empty or +sentinel digest. Where `responseOk` and `toolIo`'s error flag disagree, +`toolIo` is authoritative. It sits beside the legacy `responseDigest`, not +in place of it. + +### `grant-use`, from `bin/hook-pre-tool-use.js` (grant path of the gate branch) + +Appended via `appendReceiptGuarded` together with the grant decision itself, +inside the chain lock: the use count is read and the entry lands on the same +view, so two overlapping calls under one grant cannot both pass the ceiling. + +- `grantId`, `useIndex`, `ruleId`, `tool`, `agentId`/`agentType` (subagent + calls only), `paramsDigest` (16-hex over the full tool input), + `matcherHash`, `timestamp`. +- **No `sessionId`.** The grant itself is session-bound and the resolution + was given the session id, but the row does not carry it; a grant use is + attributed to the grant, not the session. +- If the use cannot be recorded, the call is denied: the ceiling is enforced + by counting these rows, so an unrecorded use would be an uncounted one. + +### `delegation-grant`, from `src/grant/issue.js` (`recordGrantOnChain`) + +The type is the `GRANT_TYPE` constant (`'delegation-grant'`) in +`src/grant/grant-schema.js`, so a scan for `type:` string literals will not +find it at the append site; this doc names it anyway. + +- `grantId`, `sessionId`, `requests`, `maxActions`, `issuedAt`, + `expiresAt`, `nonce`, `signature` (the owner's, verifiable against the + approval public key, which is passphrase-derived and never on disk), + `timestamp`. + +**This is the one writer that puts exact request parameters on the chain in +plaintext, including command strings.** The source comment states why: a +digest would let someone verify a grant file they still have, not reconstruct +what was authorized once the file is gone, and reconstruction is the +requirement. The chain entry is the record, not the authority: a grant is +honoured because it carries a valid signature, and the verifier deliberately +does not require a chain row. + +### `reasoning-step`, from `src/reasoning/index.js` (`captureStep`, appended by `recordReasoningStep`, triggered from `bin/hook-post-tool-use.js` (`main`)) + +One per tool call, before the egress fast path, so every call gets a step +whether or not it matched an egress rule. Never throws; a failure is a note, +never a changed outcome. + +- always: `type`, `schema: 'reasoning-step/1'`, `sessionId` (null when the + payload had none), `toolUseId`, `tool`, `captureMs`, `timestamp`, and + `agentId`/`agentType` when the call was made inside a subagent (a child + has its own cursor file under `cursors/agents/` and never advances the + parent's). +- recorded path adds: `status: 'recorded'`, `matchedBy` (`tool_use_id` or + `name+input`), `kind`, `kinds`, `blocks`, `chars`, `digests` (one row per + thinking block, up to a cap, `{ block, sha256, length }`; + `digestsTruncated` names the overflow), `segmentDigest`, `batchIndex`, + and a `transcript` binding for the I/O the capture performed. +- unrecorded path: `status: 'unrecorded'`, `reason` (`no-session-id`, + `agent-id-invalid`, `subagent-transcript-not-found`, `no-transcript-path`, + `transcript-missing`, `transcript-unreadable`, `tool-use-not-in-transcript`), + `kind: 'unrecorded'`, `kinds`, and the same `transcript` binding. + +**No content is on the chain.** Thinking blocks and transcript segments are +bound by digest and length. The cursor read-advance-write runs inside the +chain lock, so parallel tool calls cannot interleave cursor updates. + +### `ledger-head`: no writer in this repository + +Observed in the chain on 2026-07-26 at seq 727 and 728. Fields: +`limit_id`, `limit_text_sha256`, `entry_count`, `head_hash`. + +Two things about it are worth stating plainly. It is discriminated by **`kind`**, not `type`, and its fields are snake_case -where every other payload is camelCase. So the chain has two naming conventions -and two discriminator fields. Any reader checking only `type` labels these rows -`unknown`. Both readers in this repo did until this pass; both were fixed in the -same commit as this document. - -And **nothing in this repository writes it.** A grep for the string across all -JavaScript finds it only in a comment, a test, and the chain itself. It is -produced by the attempt-ledger work, which lives in a separate staging tree. A -chain that accepts entries from writers outside the repository is not wrong, but -it means **this table cannot claim to be complete**, and any future audit should -re-derive it from the chain rather than from this file. +where every other payload is camelCase. So the chain has two naming +conventions and two discriminator fields. Any reader checking only `type` +labels these rows `unknown`. + +And **nothing in this repository writes it.** A grep for the string across +the writers finds it only in comments and tests. It is produced by the +attempt-ledger work, which lives in a separate staging tree outside +`src/` and `bin/` (not checked in this pass). A chain that accepts entries +from writers outside the repository is not wrong, but it means **this table +cannot claim to be complete**, and any future audit should re-derive it from +the chain rather than from this file. --- ## What the chain does not record, anywhere -**Intent.** No entry carries why anything was done. Receipts are behavioural +**Intent.** No entry carries why anything was done. The agent's `description` +is used to build the denial message and is staged for display alongside a +request, deliberately off the chain, so it can never bind a signature or be +voided by rewording (`bin/hook-pre-tool-use.js`). Receipts are behavioural metadata: which named tools ran, against digests of their parameters. Every surface built on them has to say so, and the views in this repo do. -**What was approved.** See `gated-action` above. The tool, never the target. - -**Paths, except in one place.** Only the session receipt's `touched` array -carries paths. Gate decisions, warnings and egress events all reduce their -subject to a digest. A digest proves two things are the same and tells you -nothing about either. - -**Session attribution for gate decisions.** `gated-action` has no `sessionId`. -With concurrent sessions running, a denial cannot be attributed to the session -that caused it, and attributing by timestamp would be a guess that is wrong -often enough to be worse than an honest gap. +**Reasoning content.** The `reasoning-step` receipts and the session +receipt's thinking digests bind thinking-block and transcript-segment +**digests**. The words themselves are never on the chain. + +**What was approved, as text.** An approved `gated-action` row carries the +params digest, the nonce, the tool, and nothing else. The params themselves +never reach a gate row. What changed since 2026-07-26: **grant-authorized +actions are no longer fully in this gap.** The `delegation-grant` row records +the exact authorized requests in plaintext, by design, and each `grant-use` +row records the digest of the params of the call that spent it. So the chain +pairs a legible authorized request against each granted use. For a +token-approved action the chain still cannot show the approved text: the +match to a charter item is reconstructed by candidate comparison in +`autograph.js`. + +**Plaintext, and where it is.** Most rows reduce their subject to a digest, +which proves two things are the same and tells you nothing about either. +These fields hold readable text: the session receipt's `touched` paths and +`sent` targets (URLs, search queries, the first 100 characters of +network-shaped Bash commands, see KNOWN-LIMITS 87); `session-open` `cwd`, +`transcriptPath` and `toolDefs`; `delegation-grant` `requests` (see +KNOWN-LIMITS 18). So "the chain holds digests" is not a safe summary, and a +chain is not safe to share just because it is digest-heavy. + +**Session attribution for gate decisions, and for other rows.** `gated-action`, `egress-event`, +`session-open`, `reasoning-step` and the `both-layers-permissive` warn carry +a session id (`null` when the writer had none). What still carries none: + +- `policy-warn` in its `engine-error`, warn-mode, and unknown-mode shapes. +- every `grant-use` row, which is attributed to the grant, not the session. + +`agentId`/`agentType` name the subagent a call was made inside, on every row +that carries them, and are present only for subagent calls (the hook payload +carries the parent's session id plus those two fields). Rows written before +each field's date simply have no key for it. + +**Cost in money.** Tokens are recorded, per model now (`cost.byModel`). The +receipt's own note says no USD is derivable from the source. + +**Turn boundaries.** No entry says where one operator prompt ended and the +next began. A session is an open row and close receipts with totals; the +order of tool calls is on the chain through `reasoning-step` rows, one per +call, but nothing on the chain marks which operator prompt a call answered. +The session receipt's `chapters` +binding (2026-09-07) carries a digest of the chapter list, so a reader +recomputing chapters from the transcript named in +`session-open.transcriptPath` can tell whether they got the list the +receipt bound. What the view prints is still a claim about that file, +cross-checked by digest, never a claim the chain itself makes. **Completeness.** Capture is self-attested. A clean chain proves nothing was altered after the fact. It does not prove anything was written in the first place. **Silence is not safety**, and an empty report means nothing was -recorded rather than nothing happened. - -**Cost per model or per harness.** A session touching several models reports one -blended total under whichever ran last. - -**Turn boundaries.** No entry says where one operator prompt ended and the next -began. A session is an open row and a close receipt with totals; the sequence -inside it is not on the chain. `src/views/chapters.js` reconstructs that -sequence from the transcript file named in `session-open.transcriptPath`, and -what it prints is a claim about that file, cross-checked against the receipt's -`transcriptHash`, never a claim the chain itself makes. Codex sessions have no -chain rows at all. See KNOWN-LIMITS 71. +recorded rather than nothing happened. The hooks make this concrete: the +post hook writes no egress row on its fast path, the pre hook exits with no +write when no rule matched, and a receipt append that throws is a note on +stderr, not a row. --- ## Consequences already acted on -- `query_receipts` reported every row through one shape with `|| 0`, so a gate - denial and an idle session were indistinguishable. Fixed; **absent is now - omitted rather than zeroed**. -- `sessions_since` groups by session and reports gate decisions unattributed - rather than guessing. -- `autograph.js` attributes signatures by charter **window**, because the - per-signature version is not derivable. -- Both readers now accept `kind` as well as `type`. +- `query_receipts` reports every row as `type ?? kind ?? session` + (`src/mcp/server.js`, `summarizeEntry`) and **omits a field that does not + apply rather than zeroing it**, so a gate denial and an idle session are + distinguishable. Both naming conventions are read; `ledger-head` rows are + reported with no session id. +- `src/views/since.js`, behind `sessions_since`, groups by session but still + lists every `gated-action`, `policy-warn` and `egress-event` row as + unattributed and prints the caveat that gate decisions carry no session + id. That caveat is false for rows since 2026-09-07; KNOWN-LIMITS 74 names + it as open. +- `autograph.js` attributes signatures by charter window, with digest + candidate matching computed beside it, because full per-signature + attribution is not derivable from the gate row alone. +- Readers accept `kind` as well as `type` (`src/mcp/server.js`, + `summarizeEntry`). ## What this audit did not do -It did not read every hook end to end; it read the receipt-construction sites -and the fields they set. A field set conditionally somewhere below those lines -would not appear here. The honest scope is **what each writer constructs at the -point it constructs it**, and a fuller pass should walk each hook in full. +It did not read every writer end to end; it read the receipt-construction +sites and the fields they set there, with the helper functions named in the +citations. This pass walked every append site in `src/` and `bin/`, which is +wider than the 2026-07-26 pass, but not the whole graph. Specifically not +fully chased: the normal-success shape of the session receipt's `reasoning` +field, the inner fields of the ingest `subagents` summary, the basis rules +of `resolveHarness` (`src/harness.js`), and the inner nesting of the reach +certificate's per-model rung maps. A field constructed conditionally deeper +inside one of these helpers could appear on the chain without appearing in +this file. `test/docs-receipt-coverage.test.js` trips on a **new `type:` +literal** landing without a doc update, and its second check reads the +`GRANT_TYPE` constant in `src/grant/grant-schema.js` as text and requires +the `delegation-grant` type to be named in the doc. What the tripwire still +cannot see: a new field on an existing shape, a type passed through some +other constant, and any new entry kind written outside this repository, so +this table still cannot claim to be complete for writers it has never read. diff --git a/test/docs-receipt-coverage.test.js b/test/docs-receipt-coverage.test.js new file mode 100644 index 0000000..3e7eea8 --- /dev/null +++ b/test/docs-receipt-coverage.test.js @@ -0,0 +1,133 @@ +/** + * test/docs-receipt-coverage.test.js + * + * Tripwire for docs/receipt-coverage.md (WO-COVERAGE-RESYNC, 2026-11-05). + * + * The doc claims to enumerate every chain receipt shape. That claim went + * stale once, silently, between 2026-07-26 and 2026-11-05, because the code + * gained writers and nobody reread the doc. This test makes the claim + * checkable: it walks src/ and bin/ as PLAIN TEXT, collects every string + * literal written into a `type: ''` + * property, and asserts each one appears in the doc in backticks. It reads + * files and imports nothing from src/, bin/ or the doc's subject matter, so + * a code change that adds a receipt type cannot land without either naming + * it in the doc or explicitly allow-listing a literal that is not a receipt. + * + * Declared gap of the method, narrowed 2026-11-05: `delegation-grant` + * reaches the chain via the GRANT_TYPE constant (src/grant/grant-schema.js), + * not a `type:` string literal at the append site, so the literal scan + * cannot see it. The second check below reads that constant as text and + * pins its value in the doc, so `delegation-grant` is no longer beyond the + * tripwire. What stays beyond it: a type passed through some OTHER constant, + * a new field on an existing shape, and any new entry kind written outside + * this repository. The doc's "What this audit did not do" repeats this. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, '..'); +const SCAN_DIRS = ['src', 'bin']; +const DOC_PATH = path.join(ROOT, 'docs', 'receipt-coverage.md'); +const GRANT_SCHEMA_PATH = path.join(ROOT, 'src', 'grant', 'grant-schema.js'); + +// String literals this scan finds in a `type: ''` property that are +// NOT chain receipt types, subtracted from the assertion. Currently empty: +// every kebab-case `type:` literal in src/ and bin/ writes a receipt +// (session-open, gated-action, policy-warn, egress-event, grant-use, +// reasoning-step). Literals without a hyphen (key encodings like 'spki' and +// 'pkcs8', JSON-schema types like 'object' and 'string') never match the +// pattern, so they need no entry here. If a future kebab-case `type:` +// literal is NOT a receipt type (an enum value, a config key), add it here +// with a one-line reason, never silently. +const NOT_RECEIPT_TYPES = []; + +// A `type:` property set to a quoted kebab-case literal. The lookbehind +// keeps property accesses like `payload.type` and member names like +// `entryType:` out of the match. Both quote styles, because source and +// comments write both. +const TYPE_PROP_RE = /(? sorted list of files it appears in. + */ +function collectTypeLiterals() { + const found = new Map(); + for (const dirName of SCAN_DIRS) { + for (const file of walkJsFiles(path.join(ROOT, dirName))) { + const text = fs.readFileSync(file, 'utf8'); + for (const match of text.matchAll(TYPE_PROP_RE)) { + const literal = match[2]; + if (NOT_RECEIPT_TYPES.includes(literal)) continue; + const rel = path.relative(ROOT, file).split(path.sep).join('/'); + if (!found.has(literal)) found.set(literal, new Set()); + found.get(literal).add(rel); + } + } + } + return found; +} + +describe('docs/receipt-coverage.md: tripwire against silent staleness', () => { + it('names every chain receipt type written in src/ and bin/, in backticks', () => { + const doc = fs.readFileSync(DOC_PATH, 'utf8'); + const found = collectTypeLiterals(); + + // Guard the guard: an empty scan means the walk or the regex broke, not + // that the doc is complete. + assert.ok( + found.size > 0, + 'the scan found no `type:` literals under src/ and bin/; the walk or the regex is broken, not the doc' + ); + + const missing = []; + for (const [literal, files] of found) { + if (!doc.includes('`' + literal + '`')) { + missing.push(`${literal} (written in: ${[...files].sort().join(', ')})`); + } + } + + assert.deepStrictEqual( + [], + missing, + 'docs/receipt-coverage.md is stale: these `type:` literals appear in source but not in the doc ' + + '(in backticks). Either update the doc or, if the literal is not a chain receipt type, ' + + 'allow-list it in test/docs-receipt-coverage.test.js with a reason. Missing: ' + + missing.join('; ') + ); + }); + + it('names the delegation-grant type, read from the GRANT_TYPE constant', () => { + // GRANT_TYPE never appears as a `type:` literal at the append site + // (src/grant/issue.js passes it through), so the literal scan above + // cannot see it. This check closes that specific gap: read the constant + // as text and require the doc to name its value. A type passed through + // some other constant stays beyond both checks. + const doc = fs.readFileSync(DOC_PATH, 'utf8'); + const schemaText = fs.readFileSync(GRANT_SCHEMA_PATH, 'utf8'); + const match = schemaText.match(/GRANT_TYPE\s*=\s*(['"`])([^'"`]+)\1/); + assert.ok(match, 'GRANT_TYPE not found in src/grant/grant-schema.js; the scan or the constant is broken'); + const grantType = match[2]; + assert.ok(grantType.length > 0, 'GRANT_TYPE is empty'); + assert.ok( + doc.includes('`' + grantType + '`'), + 'docs/receipt-coverage.md is stale: the GRANT_TYPE constant ("' + grantType + '") ' + + 'is not named in the doc in backticks' + ); + }); +}); diff --git a/test/known-limits-87-sent-plaintext.test.js b/test/known-limits-87-sent-plaintext.test.js new file mode 100644 index 0000000..bb3aae0 --- /dev/null +++ b/test/known-limits-87-sent-plaintext.test.js @@ -0,0 +1,91 @@ +/** + * test/known-limits-87-sent-plaintext.test.js + * + * Tripwire for KNOWN-LIMITS 87 (2026-11-05). It pins the parser's current + * behaviour on `sent.items[].target`: the value is plaintext, nothing + * redacts it, and the Bash network test is a bare substring over the + * lowercased command, so it matches wider than the verb names suggest. + * This pins today's behaviour; it is not an endorsement of it. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSession } from '../src/parser/index.js'; + +// Synthetic assistant tool_use line, shaped exactly like the real thing. +const toolUseLine = (id, name, input) => JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: '2026-11-05T00:00:00Z', + message: { + model: 'm', + role: 'assistant', + content: [{ type: 'tool_use', id, name, input }] + } +}); + +// All values below are obviously fake and go nowhere. They exist so the +// pinned assertions are about shape, not about any real credential. +const LONG_COMMAND = 'curl https://example.test/' + 'FAKE'.repeat(40); // > 100 chars + +const jsonl = [ + toolUseLine('t0', 'Bash', { command: 'curl -H "Authorization: Bearer FAKE-TOKEN" https://example.test/x' }), + toolUseLine('t1', 'WebFetch', { url: 'https://example.test/x?api_key=FAKE-KEY' }), + toolUseLine('t2', 'WebSearch', { query: 'FAKE-QUERY private search words' }), + toolUseLine('t3', 'Bash', { command: LONG_COMMAND }), + toolUseLine('t4', 'Bash', { command: 'cat mapping.txt' }), + toolUseLine('t5', 'Bash', { command: 'ls -la' }), + toolUseLine('t6', 'Write', { file_path: 'FAKE.md', content: '# fake' }) +].join('\n'); + +const receipt = parseSession(jsonl); +const sent = receipt.sent; +const items = sent.items; + +function findTargetSubstring(tool, needle) { + return items.filter(i => i.tool === tool && i.target.includes(needle)); +} + +describe('KNOWN-LIMITS 87: sent targets are plaintext', () => { + it('keeps the capture note (self-attested) beside the items', () => { + assert.ok(typeof sent.captureNote === 'string'); + }); + + it('carries a Bash curl command verbatim as a target, credential text included', () => { + const [hit] = findTargetSubstring('Bash', 'FAKE-TOKEN'); + assert.ok(hit, 'no sent item holds the curl command text'); + assert.equal(hit.target, 'curl -H "Authorization: Bearer FAKE-TOKEN" https://example.test/x'); + }); + + it('carries a WebFetch URL verbatim, query string included', () => { + const [hit] = findTargetSubstring('WebFetch', 'api_key=FAKE-KEY'); + assert.ok(hit, 'no sent item holds the WebFetch URL'); + assert.equal(hit.target, 'https://example.test/x?api_key=FAKE-KEY'); + }); + + it('carries a WebSearch query verbatim', () => { + const [hit] = findTargetSubstring('WebSearch', 'FAKE-QUERY private search words'); + assert.ok(hit, 'no sent item holds the WebSearch query'); + assert.equal(hit.target, 'FAKE-QUERY private search words'); + }); + + it('truncates a long Bash command to exactly 100 characters', () => { + assert.ok(LONG_COMMAND.length > 100); + const [hit] = findTargetSubstring('Bash', 'curl https://example.test/'); + assert.ok(hit, 'long curl command did not produce a sent item'); + assert.equal(hit.target.length, 100); + assert.equal(hit.target, LONG_COMMAND.slice(0, 100)); + }); + + it('qualifies a non-network command through a bare substring (pinning the wide match)', () => { + const hit = items.find(i => i.tool === 'Bash' && i.target === 'cat mapping.txt'); + assert.ok(hit, 'cat mapping.txt did not land in sent; the bare substring match on ping has changed'); + }); + + it('writes no sent item for a plain ls or a Write call', () => { + assert.ok(!items.some(i => i.target.includes('ls -la')), 'plain ls produced a sent item'); + assert.ok(!items.some(i => i.tool === 'Write'), 'a Write call produced a sent item'); + // Everything else in the transcript is accounted for above. + assert.equal(items.length, 5); + }); +});