Skip to content

Latest commit

 

History

History
267 lines (216 loc) · 13.1 KB

File metadata and controls

267 lines (216 loc) · 13.1 KB

docs/receipts.md — Tool Receipts

Purpose: define CrustCore's tool receipts — the ToolReceipt struct, the rule that no model-visible tool claim exists without a receipt, how receipts tie a model-visible result to a real tool call, the MAC/hash chain, artifact handles vs inline text, and how receipts defend against hallucinated tool results.

Cross-links: ROADMAP.md §7.4 (ToolReceipt), ROADMAP.md §16.3 (artifacts), ROADMAP.md §1.3 (ZeroClaw lesson), ROADMAP.md §18 Phase 2, INVARIANTS.md #10, THREAT_MODEL.md. Sibling docs: event-log.md (receipts ride the hash chain), policy.md, backend-contract.md, secrets.md, security-model.md.


1. Why receipts exist (the ZeroClaw lesson)

Tool receipts. Every model-visible tool result should carry a receipt tying it to a real tool call, args hash, result hash, event seq, and task/job identity. (ROADMAP.md §1.3)

A coding agent that can claim a tool ran — "I ran the tests and they passed" — without that claim being bound to a real tool call is unsafe: the model can hallucinate tool results, and a hallucinated "tests passed" defeats verifier-owned completion. Receipts make a model-visible tool result unforgeable evidence that a specific tool call really happened with specific arguments and produced a specific result. This is invariant 10: every model-visible tool result has a receipt.


2. The ToolReceipt struct

pub struct ToolReceipt {
    pub task_id: TaskId,
    pub job_id: JobId,
    pub tool_call_id: ToolCallId,
    pub tool_name_hash: [u8; 32],
    pub args_hash: [u8; 32],
    pub result_hash: [u8; 32],
    pub artifact_hashes: Vec<ArtifactId>, // hard-capped at 256
    pub event_seq: EventSeq,
    pub prev_receipt_hash: [u8; 32],
    pub mac: [u8; 32],
}
Field Binds the receipt to… Rationale
task_id the owning task identity — which task this evidence belongs to
job_id the owning job identity — which job/lease ran it (invariant 12)
tool_call_id the specific tool invocation one receipt ↔ one tool call
tool_name_hash which tool proves the tool's identity without leaking the raw name into the receipt structure
args_hash the exact arguments proves the call was made with these args, not retold ones
result_hash the exact result binds the model-visible result to the receipt — tampering with the shown result breaks the hash
artifact_hashes up to N produced artifacts ties heavy outputs (diffs, logs) by content hash, not inline text (§7)
event_seq the event-log frame anchors the receipt into the hash-chained log (event-log.md)
prev_receipt_hash the previous receipt chains receipts so the whole sequence is tamper-evident (§6)
mac a key the model never holds makes receipts unforgeable by the model/worker (§6)

args_hash, result_hash, and artifact_hashes are content hashes; the receipt commits to content without re-embedding it. Artifact references are capped at 256 before storage (invariant 11).


3. The rules

No receipt -> no model-visible claim that a tool ran.
Receipts are generated by CrustCore, never by the model.
Receipts are checked during replay/inspect.
Receipts do not include secret values.

(ROADMAP.md §7.4)

  1. No receipt → no model-visible tool claim. A tool result may only be surfaced to the model as "this tool ran and produced this" if a ToolReceipt exists for it. This is enforced at the boundary where Event::ToolCompleted is minted (architecture.md §3.1): the receipt is part of the event, and a model-visible frame without a corresponding receipt is rejected (visibility field in the event frame — event-log.md §3). Tested by the "no receipt → no model-visible claim" test (INVARIANTS.md #10, acceptance criterion in ROADMAP.md §18 Phase 2).
  2. Receipts are generated by CrustCore, never by the model. The model cannot mint, forge, or edit a receipt — it has no constructor and no MAC key (§5, §6). A model "claiming" a receipt is just untrusted data (invariant 7).
  3. Receipts are checked during replay/inspect. crustcore inspect (event-log.md §8) re-verifies each receipt against the logged args/result/artifact hashes and the receipt chain. A receipt that does not match the logged tool call is a detected break.
  4. Receipts do not include secret values (invariant 10, and consistent with invariants 1–3). They carry hashes of args/results, not the raw bytes, so a receipt is safe to keep, export, and show. Secret-bearing args/results must already be redacted before hashing for any model-visible surface (secrets.md, security-model.md).

For real CLI and daemon tasks, the stable binary receipt record is the payload of the exact ToolCallCompleted frame named by event_seq. The owner-only MAC key is stored at $CRUSTCORE_STATE/receipt.key (or the XDG state fallback), and each run log is atomically persisted mode 0600 under runs/. Completion happens only after the MAC chain, event-log chain, and receipt-to-frame join all verify and the log is durable.


4. How a receipt ties a model-visible result to a real tool call

The chain of bindings that turns "the model says X ran" into "X provably ran":

real tool call  ─┬─ tool_call_id   (this exact invocation)
                 ├─ tool_name_hash (this exact tool)
                 ├─ args_hash      (with these exact arguments)
                 ├─ result_hash    (producing this exact result shown to the model)
                 ├─ artifact_hashes(and these exact artifacts)
                 ├─ event_seq      (logged at this point in the hash chain)
                 ├─ task_id/job_id (under this task/job identity)
                 └─ mac            (signed with a key the model never sees)

Because the model-visible result's hash is result_hash, the model cannot show the user a different result than the one that was actually produced without breaking the receipt. Because event_seq anchors into the event log (event-log.md) and tool_call_id/args_hash bind the exact call, an inspector can walk from a claim back to the real ToolCallStarted / CommandStarted / ToolCallCompleted events and confirm they happened. This is the ZeroClaw lesson made concrete: a model-visible tool result is evidence, not narration.


5. Receipts are generated by CrustCore, never by the model

The trusted code that executes the tool (the runner/sandbox/adapter under a capability token — policy.md, invariants 8, 9) computes the hashes over the actual args and the actual result, looks up the current event_seq and prev_receipt_hash, and computes the mac with a key held only by CrustCore. The model never participates: it has no access to the MAC key, no receipt constructor, and its output is untrusted data (invariant 7, architecture.md §3). A receipt fabricated by a model or worker fails MAC verification.


6. MAC chain and prev_receipt_hash chaining

Receipts are both authenticated and chained:

  • MAC (mac: [u8; 32]): each receipt is authenticated with a MAC keyed by a secret CrustCore holds. This is what makes a receipt unforgeable — even an attacker who can read the log cannot mint a receipt that verifies, because they lack the key. (The event-log hash chain is tamper-evident; the receipt MAC adds tamper-resistance against forgery — event-log.md §4.)
  • prev_receipt_hash: each receipt commits to the previous receipt's hash, forming a receipt chain parallel to the event chain. Deleting, reordering, or inserting a receipt breaks the chain. Verification walks the chain and the MAC together during replay/inspect.
receipt[n].prev_receipt_hash == H(receipt[n-1])
receipt[n].mac                == MAC_k(receipt[n] fields ‖ prev_receipt_hash)

Edge cases / testing notes:

  • Genesis receipt: prev_receipt_hash is a fixed genesis constant.
  • Forged receipt (wrong key): MAC verification fails.
  • Mismatched result: a receipt whose result_hash ≠ hash of the shown result is rejected.
  • Replay/reorder: a receipt out of chain order is detected via prev_receipt_hash.

7. Artifact handles vs inline text

Tool returns give summaries and handles, not megabytes of text (ROADMAP.md §16.3, CLAUDE.md §6.5 "bounded everything"). Heavy outputs — diff, patch, test-log, build-log, sandbox-transcript, model-summary, review, security-report, github-comment, mcp-result, receipt-bundle (ROADMAP.md §16.3) — live in the content-addressed artifact store (event-log.md §7) and are referenced from the receipt by their content hash in artifact_hashes.

Why this matters for receipts:

  • A receipt stays small and bounded regardless of how large the tool output was.
  • The content hash in artifact_hashes proves which artifact was produced; the model sees a handle + summary, not the raw bytes, keeping model context lean (invariant 20) and keeping potentially secret-bearing bulk out of model visibility until redacted (invariants 2, 3).
  • An inspector can fetch the artifact by hash and confirm it matches the receipt.

8. Relationship to the event log

Receipts and the event log are two interlocking chains:

  • Each receipt's event_seq points at the ToolCallCompleted frame in the hash-chained event log (event-log.md).
  • The event frame's visibility field, together with the presence of a receipt, decides whether a tool result may enter model context (§3 rule 1).
  • crustcore inspect verifies both chains: the event-log frame_hash/prev_hash chain (event-log.md §4) and the receipt MAC/prev_receipt_hash chain (§6). A break in either is reported.
  • The receipt↔log join ([crustcore_receipts::join::verify_against_log]) closes the last seam: it cross-checks that every receipt's event_seq resolves to a frame that exists, is a ToolCallCompleted, and carries the same task_id and job_id. So a receipt is provably tied to a logged event, not merely self-consistent — a forged receipt pointing at a missing seq, a non-tool frame, or another task's frame is detected (NoFrameAtSeq / NotAToolCompletion / TaskMismatch / JobMismatch). To keep crustcore-receipts tiny (it links into nano), the join takes a log-agnostic FrameRef per frame rather than depending on the event-log crate; the caller — which holds the EventLog — extracts them. The selftest path exercises the join end to end against real artifacts.
  • ToolReceipt is also the proof object embedded in a VerifiedPatch (backend-contract.md, ROADMAP.md §7.5): the verifier's clean-sandbox run produces a receipt that ties the passing verification to real command evidence (invariant 13).

9. What receipts defend against

Receipts are the primary defense against model hallucinated tool results — one of the named threats (ROADMAP.md §9.2, THREAT_MODEL.md). Because a model-visible tool claim without a matching, MAC-verified, chained receipt is rejected, the model cannot:

  • claim a tool ran that did not run (no receipt exists);
  • claim a different result than was produced (result_hash mismatch);
  • claim a different artifact (artifact_hashes mismatch);
  • forge a receipt (no MAC key);
  • retell an old receipt for a new call (tool_call_id/event_seq/chain mismatch).

Combined with verifier-owned completion (invariant 13; backend-contract.md), this closes the loop: a patch is "done" only on real verifier evidence, and that evidence is itself receipted.


10. Phase 2 tasks and acceptance

Tasks (ROADMAP.md §18 Phase 2): P2.3 "Implement ToolReceipt generation" (alongside P2.1–P2.6 for the event log — event-log.md §9).

Acceptance (ROADMAP.md §18 Phase 2):

Tampered log is detected.
Tool result without receipt cannot become model-visible.
`inspect` shows task summary and chain status.

This satisfies v0.1 DoD #7 ("tool results have receipts") and contributes to #11 ("red-team fixtures for prompt injection, path escape, and fake tool results pass") — specifically the "model fabricates tool result" fixture (ROADMAP.md §19.3, §22; INVARIANTS.md red-team requirement). A change that adds a new model-visible tool surface must add its receipt path and the corresponding red-team fixture in the same PR.