Skip to main content

polyc_crypto/
approval.rs

1//! HITL approval payload encoders/decoders and signer.
2//!
3//! The approval flow persists two event payloads in the conversation eventlog:
4//!
5//! * `approval_request` — minted when `polyc_agent::run_turn` surfaces a
6//!   pending tool call that needs human consent.
7//! * `approval_response` — minted by `ApprovalService::Respond` (a human
8//!   decision) or, non-interactively, by the `POLYCHROME_APPROVAL_MODE`
9//!   machine-approval path (the reviewer-agent auto-review or the blanket
10//!   approve-all mode) carrying a signed decision, distinguishable from a human
11//!   one only by its signed `reason` (see [`auto_review_reason`] /
12//!   [`AUTO_REVIEW_REASON_PREFIX`] / [`APPROVE_ALL_DANGEROUS_REASON`]).
13//!
14//! The encoders / decoders live in `polyc-crypto` (this crate) rather
15//! than the control-plane binary so the harness pod can verify inbound
16//! `approval_response` payloads on its own — the harness sits in a sandbox
17//! and shouldn't trust the wire blindly.
18
19use std::collections::HashSet;
20use std::sync::Arc;
21
22use serde_json::Value;
23
24use crate::{Signer, verify};
25
26/// Ed25519 signer that mints provenance signatures for `approval_response`
27/// payloads. Wraps [`Signer`] in an `Arc` so the gRPC service and the
28/// `connect` path share one instance.
29#[derive(Clone)]
30pub struct ApprovalSigner {
31    inner: Arc<Signer>,
32}
33
34impl Default for ApprovalSigner {
35    /// Deterministic seed (V1 only). Production wires this to a secret
36    /// store; the `Default` is here purely so callers can compose.
37    fn default() -> Self {
38        Self::from_seed(1)
39    }
40}
41
42impl ApprovalSigner {
43    /// Build a signer from a deterministic seed (dev / tests). Production
44    /// keys come from a secret store; that wiring is a follow-up.
45    #[must_use]
46    pub fn from_seed(seed: u64) -> Self {
47        Self {
48            inner: Arc::new(Signer::from_seed(seed)),
49        }
50    }
51
52    /// Encoded public key bytes; clients verify approval signatures against
53    /// these.
54    #[must_use]
55    pub fn public_key_bytes(&self) -> Vec<u8> {
56        self.inner.public_key_bytes()
57    }
58
59    /// Sign the canonical bytes of a response payload. The canonical bytes
60    /// are the JSON encoding with the `signature_hex` and `signed_by` fields
61    /// cleared — same shape as [`crate::toolcall`] (signature commits to
62    /// everything except itself).
63    #[must_use]
64    pub fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
65        self.inner.sign(canonical_bytes)
66    }
67}
68
69/// JSON payload for an `approval_request` event.
70///
71/// `request_id` is the model's tool-call id — stable for the lifetime of the
72/// turn and used by `ApprovalService::Respond` to address the matching
73/// response.
74/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
75/// harness was running under when it paused this call. The control plane reads it
76/// back at respond time and signs it into the response, so a remembered approval
77/// is bound to the mode it was granted under.
78/// `reason` is the OVERRIDE explanation for why the call is gated — empty for an
79/// ordinary gated call, non-empty only for the lethal-trifecta / Rule-of-Two
80/// containment override. It is presentation + audit (NOT covered by any
81/// signature — the `approval_request` event itself is unsigned; the *response*
82/// is what gets signed), so the durable log records WHY a trifecta-gated call
83/// was paused, and the edge can render it on the approval card.
84/// `missing_capabilities` records the capability shortfall the gate computed
85/// when it paused the call (`#595`): the stable kebab-case names of the
86/// capabilities the call required but was not granted. Read back at respond
87/// time and signed into the response as `covered_capabilities`, so a
88/// remembered grant is scoped to exactly what the approver saw it cover.
89/// Empty for an ordinary policy/sandbox gate.
90#[must_use]
91pub fn request_payload(
92    request_id: &str,
93    tool_name: &str,
94    args_json: &str,
95    sandbox_mode: &str,
96    reason: &str,
97    missing_capabilities: &[String],
98) -> Vec<u8> {
99    serde_json::json!({
100        "tool_name": tool_name,
101        "args_json": args_json,
102        "request_id": request_id,
103        "sandbox_mode": sandbox_mode,
104        "reason": reason,
105        "missing_capabilities": missing_capabilities,
106    })
107    .to_string()
108    .into_bytes()
109}
110
111/// The single source for the signed `approval_response` canonical JSON. Both the
112/// signing path ([`response_payload`]) and the verifying paths
113/// ([`verify_signed_response`], [`verify_wire_response`]) route through this so
114/// the covered field set/order cannot drift. Adding/renaming/reordering here is a
115/// signed-contract change.
116///
117/// The signature binds the approval to the exact call identity (`request_id`,
118/// `tool_name`, `args_json`) so a re-emitted same-id call with different
119/// args/tool cannot inherit it, AND — for a "don't ask again" decision — to
120/// `approved_for_session` plus the `caller` the memory is scoped to, so a
121/// remembered approval is per-caller and unforgeable. A one-shot approval simply
122/// signs `approved_for_session: false`.
123///
124/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
125/// PAUSED turn ran under, resolved server-side from the request. Binding it means
126/// a remembered approval granted under one mode cannot be replayed to auto-
127/// approve a later call running under a different (e.g. more-privileged) mode —
128/// the harness re-prompts. Empty when no mode was recorded.
129///
130/// `conversation_id` and `nonce` make the response a SINGLE-USE, conversation-
131/// bound capability token (`#370`, closes `#77` bug 3B). `conversation_id` binds
132/// the approval to the one conversation it was granted in, so a signed response
133/// copied into a different conversation's log fails to verify against that
134/// conversation. `nonce` is a per-approval unique value the consumer records on
135/// use, so a captured token cannot be re-presented after it has been spent. Both
136/// are covered by the signature, so neither can be re-targeted or replayed
137/// without invalidating it.
138/// `covered_capabilities` (`#595`) records the capability shortfall this
139/// approval covered — the missing set the gate computed when it paused the
140/// call. Covered by the signature, so the effective session-grant key is
141/// (caller, tool, covered capabilities): if the tool's required set later
142/// grows, the old grant does not cover the new capability and the gate asks
143/// again. Empty for an approval of an ordinary policy/sandbox gate.
144#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
145fn response_canonical(
146    request_id: &str,
147    tool_name: &str,
148    args_json: &str,
149    modified_args_json: &str,
150    approved: bool,
151    approved_for_session: bool,
152    covered_capabilities: &[String],
153    caller: &str,
154    sandbox_mode: &str,
155    reason: &str,
156    injected_context: &str,
157    conversation_id: &str,
158    nonce: &str,
159) -> Vec<u8> {
160    serde_json::json!({
161        "request_id": request_id,
162        "tool_name": tool_name,
163        "args_json": args_json,
164        "modified_args_json": modified_args_json,
165        "approved": approved,
166        "approved_for_session": approved_for_session,
167        "covered_capabilities": covered_capabilities,
168        "caller": caller,
169        "sandbox_mode": sandbox_mode,
170        "reason": reason,
171        "injected_context": injected_context,
172        "conversation_id": conversation_id,
173        "nonce": nonce,
174    })
175    .to_string()
176    .into_bytes()
177}
178
179/// JSON payload for an `approval_response` event.
180///
181/// The signature commits to the canonical (unsigned) JSON form: the call
182/// identity (`request_id`, `tool_name`, `args_json`), the decision
183/// (`approved`), the session scope
184/// (`approved_for_session`) and the `caller` it is bound to. `signed_by` and
185/// `signature_hex` are populated *after* the signer runs and are NOT covered by
186/// the signature.
187///
188/// Each parameter is a distinct signed field, so they're passed individually
189/// rather than wrapped in a struct (the canonical form is the contract).
190///
191/// `conversation_id` binds the token to the conversation it was granted in and
192/// `nonce` is a per-approval unique value (the control plane mints a fresh one
193/// per response); together they make the approval a single-use, conversation-
194/// bound capability (`#370`). Returns
195/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
196#[must_use]
197#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
198pub fn response_payload(
199    request_id: &str,
200    tool_name: &str,
201    args_json: &str,
202    modified_args_json: &str,
203    approved: bool,
204    approved_for_session: bool,
205    covered_capabilities: &[String],
206    caller: &str,
207    sandbox_mode: &str,
208    reason: &str,
209    injected_context: &str,
210    conversation_id: &str,
211    nonce: &str,
212    signer: &ApprovalSigner,
213) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
214    let canonical_bytes = response_canonical(
215        request_id,
216        tool_name,
217        args_json,
218        modified_args_json,
219        approved,
220        approved_for_session,
221        covered_capabilities,
222        caller,
223        sandbox_mode,
224        reason,
225        injected_context,
226        conversation_id,
227        nonce,
228    );
229    let signature = signer.sign(&canonical_bytes);
230    let pk = signer.public_key_bytes();
231    let full = serde_json::json!({
232        "request_id": request_id,
233        "tool_name": tool_name,
234        "args_json": args_json,
235        "modified_args_json": modified_args_json,
236        "approved": approved,
237        "approved_for_session": approved_for_session,
238        "covered_capabilities": covered_capabilities,
239        "caller": caller,
240        "sandbox_mode": sandbox_mode,
241        "reason": reason,
242        "injected_context": injected_context,
243        "conversation_id": conversation_id,
244        "nonce": nonce,
245        "signed_by": hex_lower(&pk),
246        "signature_hex": hex_lower(&signature),
247    });
248    (full.to_string().into_bytes(), signature, pk)
249}
250
251/// Scope names for a signed taint-excision marker (`#590`).
252///
253/// `cascade` is the sound default: the named positions are excised AND so is
254/// every model-authored content event after the earliest of them — the
255/// recovery literature shows a model re-derives an injected instruction from
256/// its own retained reasoning if only the source is removed. `source-only`
257/// excises exactly the named positions: an explicit, human-vouched override
258/// for content the person read and judged benign, named in the signed
259/// payload so the audit trail shows which posture the human chose.
260pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
261/// See [`EXCISION_SCOPE_CASCADE`].
262pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
263
264/// The canonical (signature-covered) form of a `taint_excision` marker.
265///
266/// Covers the conversation (a marker signed for one conversation cannot be
267/// replayed into another), the scope, the named journal positions, and who
268/// requested the excision — so a marker can be neither forged, re-targeted,
269/// nor widened. `signed_by`/`signature_hex` are appended after signing and
270/// are not covered.
271fn excision_canonical(
272    conversation_id: &str,
273    scope: &str,
274    positions: &[u64],
275    requested_by: &str,
276    reason: &str,
277) -> Vec<u8> {
278    serde_json::json!({
279        "conversation_id": conversation_id,
280        "scope": scope,
281        "positions": positions,
282        "requested_by": requested_by,
283        "reason": reason,
284    })
285    .to_string()
286    .into_bytes()
287}
288
289/// JSON payload for a signed `taint_excision` event (`#590`).
290///
291/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
292#[must_use]
293pub fn excision_payload(
294    conversation_id: &str,
295    scope: &str,
296    positions: &[u64],
297    requested_by: &str,
298    reason: &str,
299    signer: &ApprovalSigner,
300) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
301    let canonical = excision_canonical(conversation_id, scope, positions, requested_by, reason);
302    let signature = signer.sign(&canonical);
303    let pk = signer.public_key_bytes();
304    let full = serde_json::json!({
305        "conversation_id": conversation_id,
306        "scope": scope,
307        "positions": positions,
308        "requested_by": requested_by,
309        "reason": reason,
310        "signed_by": hex_lower(&pk),
311        "signature_hex": hex_lower(&signature),
312    });
313    (full.to_string().into_bytes(), signature, pk)
314}
315
316/// A verified `taint_excision` marker.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct VerifiedExcision {
319    /// The conversation the marker is bound to.
320    pub conversation_id: String,
321    /// [`EXCISION_SCOPE_CASCADE`] or [`EXCISION_SCOPE_SOURCE_ONLY`].
322    pub scope: String,
323    /// The named journal positions.
324    pub positions: Vec<u64>,
325    /// Who requested the excision (persona id or operator identity).
326    pub requested_by: String,
327    /// Free-text audit reason.
328    pub reason: String,
329    /// The verified signer's public key (encoded).
330    pub signer_public_key: Vec<u8>,
331}
332
333impl VerifiedExcision {
334    /// Whether this marker's scope is the cascading (sound-default) one.
335    #[must_use]
336    pub fn is_cascade(&self) -> bool {
337        self.scope == EXCISION_SCOPE_CASCADE
338    }
339}
340
341/// Verify a persisted `taint_excision` payload.
342///
343/// `None` for a malformed payload, an unknown scope, or a signature that
344/// does not verify — the caller ignores the marker and taint stays (fail
345/// closed).
346#[must_use]
347pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
348    let v: Value = serde_json::from_slice(payload).ok()?;
349    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
350    let scope = v.get("scope")?.as_str()?.to_owned();
351    if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
352        return None;
353    }
354    let positions: Vec<u64> = v
355        .get("positions")?
356        .as_array()?
357        .iter()
358        .map(serde_json::Value::as_u64)
359        .collect::<Option<Vec<_>>>()?;
360    let requested_by = v.get("requested_by")?.as_str()?.to_owned();
361    let reason = v.get("reason")?.as_str()?.to_owned();
362    let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
363    let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
364    let canonical =
365        excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
366    if verify(&pk, &canonical, &sig) {
367        Some(VerifiedExcision {
368            conversation_id,
369            scope,
370            positions,
371            requested_by,
372            reason,
373            signer_public_key: pk,
374        })
375    } else {
376        None
377    }
378}
379
380/// JSON payload for an `approval_deferred` event (`#67` "send back").
381///
382/// A defer records that the approver bounced the call back without approving or
383/// denying it — the audit trail shows the intent, but the pending
384/// `approval_request` is NOT resolved (no `approval_response`), so the call stays
385/// open. The signature commits to the call identity (`request_id`), the
386/// `conversation_id` it was deferred in, and the free-form `reason`; `signed_by`
387/// and `signature_hex` are appended after signing and are not covered.
388///
389/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
390#[must_use]
391pub fn deferred_payload(
392    request_id: &str,
393    conversation_id: &str,
394    reason: &str,
395    signer: &ApprovalSigner,
396) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
397    let canonical = serde_json::json!({
398        "request_id": request_id,
399        "conversation_id": conversation_id,
400        "reason": reason,
401    })
402    .to_string()
403    .into_bytes();
404    let signature = signer.sign(&canonical);
405    let pk = signer.public_key_bytes();
406    let full = serde_json::json!({
407        "request_id": request_id,
408        "conversation_id": conversation_id,
409        "reason": reason,
410        "signed_by": hex_lower(&pk),
411        "signature_hex": hex_lower(&signature),
412    });
413    (full.to_string().into_bytes(), signature, pk)
414}
415
416/// Verify a persisted `approval_deferred` payload (`#67`).
417///
418/// Returns `Some((request_id, conversation_id, reason))` when the signature
419/// checks out against the embedded key, else `None`.
420#[must_use]
421pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
422    let v: Value = serde_json::from_slice(payload).ok()?;
423    let request_id = v.get("request_id")?.as_str()?.to_owned();
424    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
425    let reason = v.get("reason")?.as_str()?.to_owned();
426    let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
427    let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
428    let canonical = serde_json::json!({
429        "request_id": request_id,
430        "conversation_id": conversation_id,
431        "reason": reason,
432    })
433    .to_string()
434    .into_bytes();
435    verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
436}
437
438/// JSON payload for a dispatch-mutation event (`#67`, #539/#540).
439///
440/// A signed record that a policy rewrote a call's args (`tool_input_rewrite`),
441/// injected context (`tool_context_injection`), or redacted a result
442/// (`tool_result_redaction`).
443///
444/// The signature commits to the event `kind` (so a record can't be re-filed under
445/// another mutation kind), the call identity (`tool_call_id`, `tool_name`), the
446/// conversation, and the mutation's `before`/`after` (proposed→executed args,
447/// or empty→context, or original→redacted result). `signed_by` / `signature_hex`
448/// are appended after signing and not covered. Returns
449/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
450#[must_use]
451#[allow(clippy::too_many_arguments)] // each is a distinct signed field of the canonical contract
452pub fn mutation_payload(
453    kind: &str,
454    tool_call_id: &str,
455    tool_name: &str,
456    conversation_id: &str,
457    before: &str,
458    after: &str,
459    signer: &ApprovalSigner,
460) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
461    let canonical = serde_json::json!({
462        "kind": kind,
463        "tool_call_id": tool_call_id,
464        "tool_name": tool_name,
465        "conversation_id": conversation_id,
466        "before": before,
467        "after": after,
468    })
469    .to_string()
470    .into_bytes();
471    let signature = signer.sign(&canonical);
472    let pk = signer.public_key_bytes();
473    let full = serde_json::json!({
474        "kind": kind,
475        "tool_call_id": tool_call_id,
476        "tool_name": tool_name,
477        "conversation_id": conversation_id,
478        "before": before,
479        "after": after,
480        "signed_by": hex_lower(&pk),
481        "signature_hex": hex_lower(&signature),
482    });
483    (full.to_string().into_bytes(), signature, pk)
484}
485
486/// Verify a persisted dispatch-mutation payload (`#67`).
487///
488/// Returns the signed `(kind, tool_call_id, tool_name, conversation_id, before,
489/// after)` when the signature checks out against the embedded key, else `None`.
490#[must_use]
491pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
492    let v: Value = serde_json::from_slice(payload).ok()?;
493    let kind = v.get("kind")?.as_str()?.to_owned();
494    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
495    let tool_name = v.get("tool_name")?.as_str()?.to_owned();
496    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
497    let before = v.get("before")?.as_str()?.to_owned();
498    let after = v.get("after")?.as_str()?.to_owned();
499    let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
500    let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
501    let canonical = serde_json::json!({
502        "kind": kind,
503        "tool_call_id": tool_call_id,
504        "tool_name": tool_name,
505        "conversation_id": conversation_id,
506        "before": before,
507        "after": after,
508    })
509    .to_string()
510    .into_bytes();
511    verify(&pk, &canonical, &sig).then_some((
512        kind,
513        tool_call_id,
514        tool_name,
515        conversation_id,
516        before,
517        after,
518    ))
519}
520
521/// Reason-string prefix marking an `approval_response` as a reviewer-agent
522/// auto-approval (`#377`), as opposed to a human decision.
523///
524/// The reviewer signs the EXACT same canonical `approval_response` a human
525/// would — same [`response_payload`], same signer, same bound identity
526/// (`request_id` + `tool_name` + `args_json` + `caller` + `sandbox_mode`),
527/// `approved == true`, `approved_for_session == false` — so the wire/signature
528/// contract is byte-for-byte identical and every existing verify path accepts
529/// it unchanged. The ONLY field distinguishing an auto-approval from a human
530/// one is the signed `reason`, which carries this prefix. Because `reason` is
531/// covered by the signature (`response_canonical`), the distinction is
532/// unforgeable: a compromised forwarder can neither launder an auto-approval as
533/// human nor a human decision as auto without invalidating the signature. The
534/// event log is therefore auditable for machine-vs-human consent off this one
535/// signed field — the guardrail `#377` requires.
536pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
537
538/// Build the signed `reason` for a reviewer auto-approval at risk `tier`.
539///
540/// Carries [`AUTO_REVIEW_REASON_PREFIX`] so the audit log can tell it from a
541/// human decision; `tier` (e.g. `"low"`) records WHY the classifier deemed the
542/// call auto-eligible. The control plane passes the result as the `reason`
543/// argument to the SAME [`response_payload`] the human path uses, so no
544/// separate signing surface exists.
545#[must_use]
546pub fn auto_review_reason(tier: &str) -> String {
547    format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
548}
549
550/// Whether a signed `reason` marks its `approval_response` as a reviewer
551/// auto-approval (`#377`) rather than a human decision.
552///
553/// The audit distinguisher; it reads the signed `reason` field, so it cannot be
554/// spoofed without breaking the signature.
555#[must_use]
556pub fn is_auto_review_reason(reason: &str) -> bool {
557    reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
558}
559
560/// Signed `reason` recorded for the blanket `approve-all-dangerous` mode.
561///
562/// Set by `POLYCHROME_APPROVAL_MODE=approve-all-dangerous` — the legible single
563/// approve-all surface that replaced the legacy `POLYCHROME_APPROVE_ALL` flag.
564/// Unlike [`auto_review_reason`] this is NOT a risk-classified verdict: it marks
565/// an unconditional machine approval, so the audit log can tell a blanket
566/// test-rig approval apart from both a human decision and a reviewer
567/// auto-approval. Like every other reason it is covered by the signature, so the
568/// distinction is unforgeable.
569pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
570
571/// A decoded `approval_response` payload after signature verification.
572#[derive(Debug, Clone)]
573pub struct VerifiedResponse {
574    /// The tool-call id this response answers.
575    pub request_id: String,
576    /// The bound tool name — the approval applies only to this exact call.
577    pub tool_name: String,
578    /// The bound `args_json` — the model's PROPOSED args, the identity the
579    /// approval is bound to. [`Self::authorizes_call`] matches this byte-for-byte,
580    /// so a re-emitted call with different args cannot inherit the approval. This
581    /// is the args the approver saw, NOT necessarily the args that execute.
582    pub args_json: String,
583    /// The approver's EDIT to the proposed args — the args to actually execute,
584    /// or empty when the approver did not edit (execute `args_json` unchanged).
585    /// Signed, so the edit is unforgeable and auditable; the delta from
586    /// `args_json` is the recorded mutation. Resolve the effective execution args
587    /// with the pure `polyc_agent::resolve_approved_call`.
588    pub modified_args_json: String,
589    /// Whether the request was approved.
590    pub approved: bool,
591    /// Whether the approval is remembered for the rest of the session ("don't
592    /// ask again"); `false` for a one-shot approval.
593    pub approved_for_session: bool,
594    /// The capability shortfall this approval covered (`#595`): the stable
595    /// kebab-case capability names the gate reported missing when it paused
596    /// the call. Covered by the signature, so the effective session-grant key
597    /// is (caller, tool, covered capabilities) — a grant recorded against one
598    /// covered set never satisfies the same tool after its required set grows.
599    /// Empty for an approval of an ordinary policy/sandbox gate.
600    pub covered_capabilities: Vec<String>,
601    /// The caller identity the (session) approval is scoped to. Set by the
602    /// trusted control plane and covered by the signature, so a session grant
603    /// cannot be re-scoped to a different user.
604    pub caller: String,
605    /// The sandbox/permission mode the paused turn ran under, covered by the
606    /// signature so a grant cannot be replayed under a different mode.
607    pub sandbox_mode: String,
608    /// Free-form human-supplied reason.
609    pub reason: String,
610    /// Context the approver attached to inject before the tool runs — prepended
611    /// as an `internal_only` message ahead of execution, or empty when none.
612    /// Signed, so an injected instruction is unforgeable and recorded.
613    pub injected_context: String,
614    /// The conversation the approval was granted in, covered by the signature so
615    /// a token signed for one conversation cannot be replayed into another
616    /// (`#370`, closes `#77` bug 3B).
617    pub conversation_id: String,
618    /// Per-approval unique value, covered by the signature. A consumer records it
619    /// on use so the token cannot be re-presented once spent (single-use, `#370`).
620    pub nonce: String,
621    /// The verified signer's public key (encoded).
622    pub signer_public_key: Vec<u8>,
623}
624
625impl VerifiedResponse {
626    /// Whether this verified, approved token authorizes the EXACT call
627    /// `(request_id, tool_name, args_json)` — the args-binding (`#370` item 3).
628    ///
629    /// The signed `args_json` is matched byte-for-byte, so a re-emitted same-id
630    /// call with different arguments (or a different tool) is NOT authorized: a
631    /// captured approval can never be reused to run a different action. A denial
632    /// (`approved == false`) authorizes nothing.
633    #[must_use]
634    pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
635        self.approved
636            && self.request_id == request_id
637            && self.tool_name == tool_name
638            && self.args_json == args_json
639    }
640}
641
642/// Verify a persisted `approval_response` payload.
643///
644/// Returns `Some(record)` if the signature checks out against the embedded
645/// public key (the caller is responsible for trusting that public key — a key
646/// allow-list lives alongside this in production). Returns `None` if the payload
647/// is malformed, the hex fields don't decode, or the signature doesn't verify.
648#[must_use]
649pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
650    let v: Value = serde_json::from_slice(payload).ok()?;
651    let request_id = v.get("request_id")?.as_str()?.to_owned();
652    let tool_name = v.get("tool_name")?.as_str()?.to_owned();
653    let args_json = v.get("args_json")?.as_str()?.to_owned();
654    let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
655    let approved = v.get("approved")?.as_bool()?;
656    let approved_for_session = v.get("approved_for_session")?.as_bool()?;
657    let covered_capabilities: Vec<String> = v
658        .get("covered_capabilities")?
659        .as_array()?
660        .iter()
661        .map(|c| c.as_str().map(str::to_owned))
662        .collect::<Option<Vec<_>>>()?;
663    let caller = v.get("caller")?.as_str()?.to_owned();
664    let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
665    let reason = v.get("reason")?.as_str()?.to_owned();
666    let injected_context = v.get("injected_context")?.as_str()?.to_owned();
667    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
668    let nonce = v.get("nonce")?.as_str()?.to_owned();
669    let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
670    let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
671
672    let canonical = response_canonical(
673        &request_id,
674        &tool_name,
675        &args_json,
676        &modified_args_json,
677        approved,
678        approved_for_session,
679        &covered_capabilities,
680        &caller,
681        &sandbox_mode,
682        &reason,
683        &injected_context,
684        &conversation_id,
685        &nonce,
686    );
687    if verify(&pk, &canonical, &sig) {
688        Some(VerifiedResponse {
689            request_id,
690            tool_name,
691            args_json,
692            modified_args_json,
693            approved,
694            approved_for_session,
695            covered_capabilities,
696            caller,
697            sandbox_mode,
698            reason,
699            injected_context,
700            conversation_id,
701            nonce,
702            signer_public_key: pk,
703        })
704    } else {
705        None
706    }
707}
708
709/// Verify a persisted `approval_response` as a SINGLE-USE, conversation-bound
710/// capability token (`#370`).
711///
712/// Returns the verified response only when ALL hold:
713/// * the signature verifies against the embedded key (provenance);
714/// * the signed `conversation_id` equals `conversation_id` — a token signed for
715///   one conversation is rejected when presented for another (closes `#77`
716///   bug 3B);
717/// * the signed `nonce` is non-empty AND not already in `consumed` — a token
718///   that has been spent (its nonce recorded on a prior use) is rejected.
719///
720/// The caller binds the token to a specific call by matching the returned
721/// [`VerifiedResponse::authorizes_call`], and MUST record the returned
722/// [`VerifiedResponse::nonce`] into its `consumed` set before honoring it, so a
723/// second presentation of the same token is rejected. An empty nonce is treated
724/// as malformed and fails closed (every minted token carries one).
725#[must_use]
726pub fn verify_capability<S: std::hash::BuildHasher>(
727    payload: &[u8],
728    conversation_id: &str,
729    consumed: &HashSet<String, S>,
730) -> Option<VerifiedResponse> {
731    let verified = verify_signed_response(payload)?;
732    if verified.conversation_id != conversation_id {
733        return None;
734    }
735    if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
736        return None;
737    }
738    Some(verified)
739}
740
741/// Verify a wire-form `approval_response`, binding the approval to its full
742/// signed identity `(request_id, tool_name, args_json, approved,
743/// approved_for_session, caller, reason)`.
744///
745/// Used by the harness when it receives `HarnessMessage.approval_responses` over
746/// the wire and must confirm provenance AND identity before executing the paused
747/// tool or honoring a "don't ask again" grant. The `caller` is covered by the
748/// signature, so a compromised control plane cannot re-scope a remembered
749/// approval onto a different user. Returns `true` only if `signer_pk_hex +
750/// signature_hex` validates against the canonical.
751#[must_use]
752#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
753pub fn verify_wire_response(
754    request_id: &str,
755    tool_name: &str,
756    args_json: &str,
757    modified_args_json: &str,
758    approved: bool,
759    approved_for_session: bool,
760    covered_capabilities: &[String],
761    caller: &str,
762    sandbox_mode: &str,
763    reason: &str,
764    injected_context: &str,
765    conversation_id: &str,
766    nonce: &str,
767    signer_pk_hex: &str,
768    signature_hex: &str,
769) -> bool {
770    let Some(pk) = hex_decode(signer_pk_hex) else {
771        return false;
772    };
773    let Some(sig) = hex_decode(signature_hex) else {
774        return false;
775    };
776    let canonical = response_canonical(
777        request_id,
778        tool_name,
779        args_json,
780        modified_args_json,
781        approved,
782        approved_for_session,
783        covered_capabilities,
784        caller,
785        sandbox_mode,
786        reason,
787        injected_context,
788        conversation_id,
789        nonce,
790    );
791    verify(&pk, &canonical, &sig)
792}
793
794/// Whether an approval response is a session ("don't ask again") grant for
795/// `current_caller`.
796///
797/// True when it is approved, flagged for the session, and bound to a non-empty
798/// `caller` equal to the current turn's caller.
799///
800/// This is the per-USER isolation invariant — user A's remembered approval must
801/// never auto-approve user B in a shared conversation. It lives here, in one
802/// place, so the harness (which re-verifies signed wire responses) and the
803/// control plane (the in-process path) cannot drift on *who* a remembered
804/// approval applies to. Callers still gate the TOOL on its idempotency
805/// separately ([`crate`] does not know tool policy).
806#[must_use]
807pub fn is_session_grant_for(
808    approved: bool,
809    approved_for_session: bool,
810    caller: &str,
811    current_caller: &str,
812) -> bool {
813    approved && approved_for_session && !caller.is_empty() && caller == current_caller
814}
815
816/// Extract `(request_id, approved)` from an `approval_response` payload.
817///
818/// Used by replay to find which pending requests have been answered. Skips
819/// signature verification on the assumption the caller has already accepted
820/// the entry — pair with [`verify_signed_response`] when trust matters.
821#[must_use]
822pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
823    let v: Value = serde_json::from_slice(payload).ok()?;
824    let request_id = v.get("request_id")?.as_str()?.to_owned();
825    let approved = v.get("approved")?.as_bool()?;
826    Some((request_id, approved))
827}
828
829/// Every decoded field of an `approval_response` payload (unverified).
830#[derive(Debug, Clone)]
831pub struct DecodedResponse {
832    /// Tool-call id this response answers.
833    pub request_id: String,
834    /// Bound tool name.
835    pub tool_name: String,
836    /// Bound `args_json` — the model's proposed args (identity binding).
837    pub args_json: String,
838    /// The approver's edit to the proposed args (empty = unedited).
839    pub modified_args_json: String,
840    /// Approve / deny decision.
841    pub approved: bool,
842    /// Whether the approval is remembered for the session ("don't ask again").
843    pub approved_for_session: bool,
844    /// The capability shortfall this approval covered (`#595`).
845    pub covered_capabilities: Vec<String>,
846    /// The caller identity the (session) approval is scoped to.
847    pub caller: String,
848    /// The sandbox/permission mode the grant was made under.
849    pub sandbox_mode: String,
850    /// Human-supplied reason.
851    pub reason: String,
852    /// Context the approver attached to inject before execution (empty = none).
853    pub injected_context: String,
854    /// The conversation the approval was granted in (`#370` binding).
855    pub conversation_id: String,
856    /// Per-approval single-use nonce (`#370` binding).
857    pub nonce: String,
858    /// Signer public key, hex.
859    pub signer_pk_hex: String,
860    /// Signature, hex.
861    pub signature_hex: String,
862}
863
864/// Decode every field of an `approval_response` payload without verifying.
865///
866/// Used by the control plane to forward signed responses onto the harness wire;
867/// the harness re-verifies on receipt.
868#[must_use]
869pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
870    let v: Value = serde_json::from_slice(payload).ok()?;
871    Some(DecodedResponse {
872        request_id: v.get("request_id")?.as_str()?.to_owned(),
873        tool_name: v.get("tool_name")?.as_str()?.to_owned(),
874        args_json: v.get("args_json")?.as_str()?.to_owned(),
875        modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
876        approved: v.get("approved")?.as_bool()?,
877        approved_for_session: v.get("approved_for_session")?.as_bool()?,
878        covered_capabilities: v
879            .get("covered_capabilities")
880            .and_then(Value::as_array)
881            .map(|a| {
882                a.iter()
883                    .filter_map(|c| c.as_str().map(str::to_owned))
884                    .collect()
885            })
886            .unwrap_or_default(),
887        caller: v.get("caller")?.as_str()?.to_owned(),
888        sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
889        reason: v.get("reason")?.as_str()?.to_owned(),
890        injected_context: v.get("injected_context")?.as_str()?.to_owned(),
891        conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
892        nonce: v.get("nonce")?.as_str()?.to_owned(),
893        signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
894        signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
895    })
896}
897
898/// Current signed `payment_receipt` schema version.
899///
900/// **v2** makes a settled receipt self-describing: alongside the original
901/// settlement facts it covers the event `kind` (direction — without it an
902/// inbound payload could be re-filed under the outbound kind, or vice versa,
903/// since the stored `Event.kind` is not itself signed), the binding fields
904/// (`tool_call_id`, `approval_pos`, `approved_args_hash`), and an opaque
905/// `subject` — so an auditor can bind the receipt to the exact approved tool
906/// call it answered without walking the log to the separate
907/// `outbound_payment_attempt` event. **v1** (legacy, no `version` field) signed
908/// only the six settlement facts; it still *verifies* for forensics but carries
909/// no kind or binding tuple.
910pub const RECEIPT_VERSION: u64 = 2;
911
912/// The receipt body fields [`receipt_payload`] signs, named at the call site.
913///
914/// Replaces positional `&str` arguments: with the positional form, two
915/// same-typed fields (e.g. `recipient` and `method`) could be swapped at a call
916/// site and still compile, silently signing a corrupt receipt. Naming the
917/// fields here makes such a swap a compile error.
918///
919/// The field set, names, and the order they are serialized in
920/// [`receipt_payload`] are the signed-payload contract: they must NOT change
921/// without a version bump, or previously-persisted receipts stop verifying.
922///
923/// `kind` and the trailing four fields are **v2** additions: the event kind
924/// (direction) plus the binding tuple and an opaque `subject`. Inbound (the
925/// server was *paid*) receipts have no approved tool call, so they pass empty
926/// strings for the binding tuple and subject; outbound (the control plane
927/// *paid* a 402 service) receipts populate them. Both directions sign their
928/// `kind`.
929#[derive(Debug, Clone, Copy)]
930pub struct ReceiptPayload<'a> {
931    /// The event kind the payload is stored under (`payment_receipt` for
932    /// inbound, `outbound_payment_receipt` for outbound). Signed so a payload
933    /// cannot be re-filed under the other direction's kind (v2; empty for
934    /// legacy v1).
935    pub kind: &'a str,
936    /// Chain/transaction reference (e.g. tx id) the receipt settles.
937    pub reference: &'a str,
938    /// Decimal amount as a string (avoids float drift).
939    pub amount: &'a str,
940    /// Currency / asset symbol.
941    pub currency: &'a str,
942    /// Recipient address.
943    pub recipient: &'a str,
944    /// Settlement method (e.g. `tempo`).
945    pub method: &'a str,
946    /// RFC3339 settlement timestamp.
947    pub timestamp: &'a str,
948    /// The `paid_fetch` tool-call id this payment answered (v2; empty for
949    /// inbound and legacy v1).
950    pub tool_call_id: &'a str,
951    /// Decimal string of the `approval_request` log position the payment
952    /// answered (v2; empty for inbound and legacy v1).
953    pub approval_pos: &'a str,
954    /// sha256 hex of the approved `args_json` — the same idempotency-key
955    /// component the `outbound_payment_attempt` marker carries, so a verifier
956    /// can cross-check the receipt against the attempt (v2; empty otherwise).
957    pub approved_args_hash: &'a str,
958    /// Opaque principal the spend is attributed to. Currently the conversation
959    /// id; the structured agent/tenant identity is supplied later by the
960    /// declarative-catalog identity model. Treat as opaque (v2; empty otherwise).
961    pub subject: &'a str,
962}
963
964impl ReceiptPayload<'_> {
965    /// Build the canonical (unsigned) **v2** JSON the signature commits to.
966    ///
967    /// This is the SINGLE source for the signed v2 receipt field set and order:
968    /// both the signing path ([`receipt_payload`]) and the verifying path
969    /// ([`verify_signed_receipt`]) route their canonical bytes through here,
970    /// so adding, renaming, or reordering a covered field is a one-line edit
971    /// and the two paths cannot drift. The key set/order is the on-wire
972    /// signed contract and must NOT change without a version bump.
973    #[must_use]
974    fn canonical_json(&self) -> Value {
975        serde_json::json!({
976            "version": RECEIPT_VERSION,
977            "kind": self.kind,
978            "reference": self.reference,
979            "amount": self.amount,
980            "currency": self.currency,
981            "recipient": self.recipient,
982            "method": self.method,
983            "timestamp": self.timestamp,
984            "tool_call_id": self.tool_call_id,
985            "approval_pos": self.approval_pos,
986            "approved_args_hash": self.approved_args_hash,
987            "subject": self.subject,
988        })
989    }
990
991    /// Legacy **v1** canonical (the original six settlement fields, no
992    /// `version`). Retained only so receipts persisted before the v2 binding
993    /// still *verify* for forensics; new receipts always sign v2.
994    #[must_use]
995    fn canonical_json_v1(&self) -> Value {
996        serde_json::json!({
997            "reference": self.reference,
998            "amount": self.amount,
999            "currency": self.currency,
1000            "recipient": self.recipient,
1001            "method": self.method,
1002            "timestamp": self.timestamp,
1003        })
1004    }
1005}
1006
1007/// JSON payload for a `payment_receipt` event.
1008///
1009/// Mirrors [`response_payload`] exactly: the signature commits to the
1010/// canonical (unsigned) JSON form of the receipt body
1011/// (`reference`, `amount`, `currency`, `recipient`, `method`, `timestamp`).
1012/// `signed_by` and `signature_hex` are populated *after* the signer runs and
1013/// are NOT covered by the signature itself. Tampering with any body field
1014/// invalidates the signature.
1015///
1016/// The fields arrive as a single named [`ReceiptPayload`] (rather than six
1017/// positional strings) so a call site cannot silently swap two same-typed
1018/// fields; the serialized key set/order is unchanged and remains the signed
1019/// contract.
1020///
1021/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
1022#[must_use]
1023pub fn receipt_payload(
1024    fields: &ReceiptPayload<'_>,
1025    signer: &ApprovalSigner,
1026) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1027    let mut full = fields.canonical_json();
1028    let canonical_bytes = full.to_string().into_bytes();
1029    let signature = signer.sign(&canonical_bytes);
1030    let pk = signer.public_key_bytes();
1031    // The full payload is the canonical body plus the two signature fields,
1032    // which are NOT covered by the signature. Append them to the single-source
1033    // canonical object so the body field set still lives only in
1034    // `ReceiptPayload::canonical_json`.
1035    if let Value::Object(map) = &mut full {
1036        map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
1037        map.insert(
1038            "signature_hex".to_owned(),
1039            Value::String(hex_lower(&signature)),
1040        );
1041    }
1042    (full.to_string().into_bytes(), signature, pk)
1043}
1044
1045/// A decoded `payment_receipt` payload after signature verification.
1046#[derive(Debug, Clone)]
1047pub struct VerifiedReceipt {
1048    /// Chain/transaction reference (e.g. tx id) the receipt settles.
1049    pub reference: String,
1050    /// Decimal amount, as a string (avoids float drift).
1051    pub amount: String,
1052    /// Currency / asset symbol.
1053    pub currency: String,
1054    /// Recipient address.
1055    pub recipient: String,
1056    /// Settlement method (e.g. `tempo`).
1057    pub method: String,
1058    /// RFC3339 settlement timestamp.
1059    pub timestamp: String,
1060    /// Schema version (`1` = legacy settlement-only, `2` = kind + binding
1061    /// tuple present).
1062    pub version: u64,
1063    /// The signed event kind (`payment_receipt` or `outbound_payment_receipt`).
1064    /// Callers should check it matches the kind the event was stored under —
1065    /// the stored kind itself is not signed (v2; empty for v1).
1066    pub kind: String,
1067    /// The `paid_fetch` tool-call id this payment answered (v2; empty for v1).
1068    pub tool_call_id: String,
1069    /// Decimal string of the `approval_request` log position (v2; empty for v1).
1070    pub approval_pos: String,
1071    /// sha256 hex of the approved `args_json` (v2; empty for v1).
1072    pub approved_args_hash: String,
1073    /// Opaque principal the spend is attributed to (v2; empty for v1).
1074    pub subject: String,
1075    /// The verified signer's public key (encoded).
1076    pub signer_public_key: Vec<u8>,
1077}
1078
1079/// Verify a persisted `payment_receipt` payload.
1080///
1081/// Returns `Some(record)` if the signature checks out against the embedded
1082/// public key (caller trusts that key; V1 trusts any signed receipt, like
1083/// [`verify_signed_response`] — a production key allow-list is a follow-up).
1084///
1085/// Returns `None` if the payload is malformed, the hex fields don't decode,
1086/// or the signature doesn't verify.
1087#[must_use]
1088pub fn verify_signed_receipt(payload: &[u8]) -> Option<VerifiedReceipt> {
1089    let v: Value = serde_json::from_slice(payload).ok()?;
1090    let reference = v.get("reference")?.as_str()?.to_owned();
1091    let amount = v.get("amount")?.as_str()?.to_owned();
1092    let currency = v.get("currency")?.as_str()?.to_owned();
1093    let recipient = v.get("recipient")?.as_str()?.to_owned();
1094    let method = v.get("method")?.as_str()?.to_owned();
1095    let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1096    let signed_by_hex = v.get("signed_by")?.as_str()?;
1097    let signature_hex = v.get("signature_hex")?.as_str()?;
1098    let pk = hex_decode(signed_by_hex)?;
1099    let sig = hex_decode(signature_hex)?;
1100    // A legacy receipt carries no `version` key; treat it as v1. A present
1101    // `version` must equal the exact current version — any other value
1102    // (including an explicit `1`, a future `3`, or a non-integer) is refused
1103    // outright rather than verified against a guessed canonical. The v1
1104    // canonical does not cover the `version` key, so dispatching an unknown
1105    // claimed version to v1 would let a valid v1 signature verify while the
1106    // output echoed an unsigned, writer-chosen version — fail closed instead.
1107    let version = match v.get("version") {
1108        None => 1,
1109        Some(n) if n.as_u64() == Some(RECEIPT_VERSION) => RECEIPT_VERSION,
1110        Some(_) => return None,
1111    };
1112    let (kind, tool_call_id, approval_pos, approved_args_hash, subject) =
1113        if version == RECEIPT_VERSION {
1114            (
1115                v.get("kind")?.as_str()?.to_owned(),
1116                v.get("tool_call_id")?.as_str()?.to_owned(),
1117                v.get("approval_pos")?.as_str()?.to_owned(),
1118                v.get("approved_args_hash")?.as_str()?.to_owned(),
1119                v.get("subject")?.as_str()?.to_owned(),
1120            )
1121        } else {
1122            (
1123                String::new(),
1124                String::new(),
1125                String::new(),
1126                String::new(),
1127                String::new(),
1128            )
1129        };
1130    // Rebuild the canonical bytes via the SAME single source the signer used,
1131    // so the verify path can never check a different field set/order.
1132    let fields = ReceiptPayload {
1133        kind: &kind,
1134        reference: &reference,
1135        amount: &amount,
1136        currency: &currency,
1137        recipient: &recipient,
1138        method: &method,
1139        timestamp: &timestamp,
1140        tool_call_id: &tool_call_id,
1141        approval_pos: &approval_pos,
1142        approved_args_hash: &approved_args_hash,
1143        subject: &subject,
1144    };
1145    let canonical_bytes = if version == RECEIPT_VERSION {
1146        fields.canonical_json()
1147    } else {
1148        fields.canonical_json_v1()
1149    }
1150    .to_string()
1151    .into_bytes();
1152    if verify(&pk, &canonical_bytes, &sig) {
1153        Some(VerifiedReceipt {
1154            reference,
1155            amount,
1156            currency,
1157            recipient,
1158            method,
1159            timestamp,
1160            version,
1161            kind,
1162            tool_call_id,
1163            approval_pos,
1164            approved_args_hash,
1165            subject,
1166            signer_public_key: pk,
1167        })
1168    } else {
1169        None
1170    }
1171}
1172
1173/// Extract `request_id` from an `approval_request` payload.
1174#[must_use]
1175pub fn decode_request_id(payload: &[u8]) -> Option<String> {
1176    let v: Value = serde_json::from_slice(payload).ok()?;
1177    Some(v.get("request_id")?.as_str()?.to_owned())
1178}
1179
1180/// Extract `(request_id, tool_name, args_json)` from an `approval_request`
1181/// payload — the fields a v2 `approval_response` must sign to bind the approval
1182/// to the request identity.
1183#[must_use]
1184pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
1185    let v: Value = serde_json::from_slice(payload).ok()?;
1186    Some((
1187        v.get("request_id")?.as_str()?.to_owned(),
1188        v.get("tool_name")?.as_str()?.to_owned(),
1189        v.get("args_json")?.as_str()?.to_owned(),
1190    ))
1191}
1192
1193/// Extract the `sandbox_mode` an `approval_request` was emitted under.
1194///
1195/// The mode the harness was running when it paused the call. Separate from
1196/// [`decode_request_fields`] so its many callers keep their tuple shape; the
1197/// control plane signs this into the response so a remembered approval is
1198/// bound to the mode it was granted under. Empty/absent → `""`.
1199#[must_use]
1200pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
1201    serde_json::from_slice::<Value>(payload)
1202        .ok()
1203        .and_then(|v| {
1204            v.get("sandbox_mode")
1205                .and_then(Value::as_str)
1206                .map(str::to_owned)
1207        })
1208        .unwrap_or_default()
1209}
1210
1211/// Extract the override `reason` an `approval_request` carried.
1212///
1213/// Non-empty only for the lethal-trifecta / Rule-of-Two containment override;
1214/// empty/absent → `""` (an ordinary gated call, or a record written before the
1215/// field existed). Used by the edge to render the gate's explanation and by
1216/// forensics to show why a trifecta-gated call was paused.
1217#[must_use]
1218pub fn decode_request_reason(payload: &[u8]) -> String {
1219    serde_json::from_slice::<Value>(payload)
1220        .ok()
1221        .and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
1222        .unwrap_or_default()
1223}
1224
1225/// Extract the `missing_capabilities` recorded on an `approval_request`
1226/// payload (`#595`) — the capability shortfall the gate computed when it
1227/// paused the call.
1228///
1229/// Read back at respond time and signed into the response as its
1230/// `covered_capabilities`. Absent or malformed decodes to empty: the grant
1231/// then covers nothing beyond the ordinary gate, the narrow direction.
1232#[must_use]
1233pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
1234    serde_json::from_slice::<Value>(payload)
1235        .ok()
1236        .and_then(|v| {
1237            v.get("missing_capabilities")
1238                .and_then(Value::as_array)
1239                .map(|a| {
1240                    a.iter()
1241                        .filter_map(|c| c.as_str().map(str::to_owned))
1242                        .collect()
1243                })
1244        })
1245        .unwrap_or_default()
1246}
1247
1248fn hex_lower(bytes: &[u8]) -> String {
1249    let mut s = String::with_capacity(bytes.len() * 2);
1250    for b in bytes {
1251        use std::fmt::Write as _;
1252        let _ = write!(&mut s, "{b:02x}");
1253    }
1254    s
1255}
1256
1257fn hex_decode(s: &str) -> Option<Vec<u8>> {
1258    if !s.len().is_multiple_of(2) {
1259        return None;
1260    }
1261    (0..s.len())
1262        .step_by(2)
1263        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
1264        .collect()
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1270
1271    use super::*;
1272
1273    // #595: the covered capability set is part of the signed contract — it
1274    // round-trips through verification and cannot be widened after signing.
1275    #[test]
1276    fn covered_capabilities_are_signed_and_tamper_evident() {
1277        let signer = ApprovalSigner::from_seed(42);
1278        let covered = vec!["arbitrary-egress".to_owned()];
1279        let (payload, _sig, _pk) = response_payload(
1280            "req-1",
1281            "web_fetch",
1282            r#"{"url":"https://a"}"#,
1283            "",
1284            true,
1285            true,
1286            &covered,
1287            "slack:T1:U9",
1288            "workspace-write",
1289            "",
1290            "",
1291            "conv-1",
1292            "nonce-1",
1293            &signer,
1294        );
1295        let verified = verify_signed_response(&payload).expect("verifies untampered");
1296        assert_eq!(verified.covered_capabilities, covered);
1297
1298        // Widening the covered set after signing invalidates the signature.
1299        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1300        v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
1301        assert!(
1302            verify_signed_response(v.to_string().as_bytes()).is_none(),
1303            "a tampered covered set must fail verification"
1304        );
1305        // So does shrinking it to hide what a grant covered.
1306        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1307        v["covered_capabilities"] = serde_json::json!([]);
1308        assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
1309    }
1310
1311    // #595: the request records the gate's capability shortfall and decodes
1312    // it back for the respond path; absent decodes empty (covers nothing).
1313    #[test]
1314    fn request_missing_capabilities_round_trip() {
1315        let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
1316        let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing);
1317        assert_eq!(decode_request_missing_capabilities(&bytes), missing);
1318        let bare = request_payload("call-2", "grep", "{}", "", "", &[]);
1319        assert_eq!(
1320            decode_request_missing_capabilities(&bare),
1321            Vec::<String>::new()
1322        );
1323        assert_eq!(
1324            decode_request_missing_capabilities(b"{\"nope\":1}"),
1325            Vec::<String>::new()
1326        );
1327    }
1328
1329    // #590: excision markers round-trip and are tamper-evident on every
1330    // covered field — widening positions, flipping scope, or re-targeting
1331    // the conversation all fail verification (taint stays, fail closed).
1332    #[test]
1333    fn signed_excision_round_trips_and_is_tamper_evident() {
1334        let signer = ApprovalSigner::from_seed(11);
1335        let (payload, _sig, _pk) = excision_payload(
1336            "conv-1",
1337            EXCISION_SCOPE_CASCADE,
1338            &[17, 23],
1339            "persona-9",
1340            "poisoned fetch",
1341            &signer,
1342        );
1343        let v = verify_signed_excision(&payload).expect("verifies untampered");
1344        assert_eq!(v.conversation_id, "conv-1");
1345        assert!(v.is_cascade());
1346        assert_eq!(v.positions, vec![17, 23]);
1347        assert_eq!(v.requested_by, "persona-9");
1348
1349        for (field, value) in [
1350            ("positions", serde_json::json!([17, 23, 40])),
1351            ("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
1352            ("conversation_id", serde_json::json!("conv-2")),
1353            ("requested_by", serde_json::json!("someone-else")),
1354        ] {
1355            let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1356            t[field] = value;
1357            assert!(
1358                verify_signed_excision(t.to_string().as_bytes()).is_none(),
1359                "tampered {field} must fail verification"
1360            );
1361        }
1362        // An unknown scope is refused even before the signature check.
1363        let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1364        t["scope"] = serde_json::json!("everything");
1365        assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
1366        // Garbage is refused.
1367        assert!(verify_signed_excision(b"not json").is_none());
1368    }
1369
1370    #[test]
1371    fn signed_response_round_trips() {
1372        let signer = ApprovalSigner::from_seed(42);
1373        // A one-shot approval: approved_for_session = false.
1374        let (payload, _sig, _pk) = response_payload(
1375            "req-1",
1376            "rm",
1377            r#"{"path":"/etc"}"#,
1378            "",
1379            true,
1380            false,
1381            &[],
1382            "slack:T1:U9",
1383            "workspace-write",
1384            "looks fine",
1385            "",
1386            "conv-1",
1387            "nonce-1",
1388            &signer,
1389        );
1390        let verified =
1391            verify_signed_response(&payload).expect("signature verifies on untampered payload");
1392        assert!(verified.approved);
1393        assert_eq!(verified.reason, "looks fine");
1394        assert_eq!(verified.request_id, "req-1");
1395        assert_eq!(verified.tool_name, "rm");
1396        assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
1397        assert_eq!(verified.caller, "slack:T1:U9");
1398        assert_eq!(verified.conversation_id, "conv-1");
1399        assert_eq!(verified.nonce, "nonce-1");
1400        assert!(verified.approved);
1401        // A one-shot approval carries no session scope.
1402        assert!(!verified.approved_for_session);
1403    }
1404
1405    #[test]
1406    fn session_response_round_trips_with_caller_binding() {
1407        let signer = ApprovalSigner::from_seed(42);
1408        let (payload, _sig, _pk) = response_payload(
1409            "req-1",
1410            "grep",
1411            r#"{"pattern":"x"}"#,
1412            "",
1413            true,
1414            true,
1415            &[],
1416            "slack:T1:U9",
1417            "workspace-write",
1418            "remember it",
1419            "",
1420            "conv-1",
1421            "nonce-1",
1422            &signer,
1423        );
1424        let verified = verify_signed_response(&payload).expect("session signature verifies");
1425        assert!(verified.approved);
1426        assert!(verified.approved_for_session, "carries session scope");
1427        assert_eq!(verified.caller, "slack:T1:U9");
1428        assert_eq!(verified.tool_name, "grep");
1429        assert!(verified.approved);
1430    }
1431
1432    #[test]
1433    fn tampered_session_or_caller_fails_verification() {
1434        let signer = ApprovalSigner::from_seed(42);
1435        let (payload, _sig, _pk) = response_payload(
1436            "req-1",
1437            "grep",
1438            "{}",
1439            "",
1440            true,
1441            true,
1442            &[],
1443            "slack:T1:U9",
1444            "workspace-write",
1445            "ok",
1446            "",
1447            "conv-1",
1448            "nonce-1",
1449            &signer,
1450        );
1451        // Every signed field is covered: re-scoping the memory to another user,
1452        // flipping the session flag, swapping the tool, re-targeting the
1453        // conversation, or re-rolling the nonce must all fail.
1454        for (field, val) in [
1455            ("caller", Value::String("slack:T1:ATTACKER".to_owned())),
1456            ("approved_for_session", Value::Bool(false)),
1457            ("tool_name", Value::String("rm".to_owned())),
1458            ("approved", Value::Bool(false)),
1459            ("args_json", Value::String("EVIL".to_owned())),
1460            // The approver's edit and injected context are signed too: forging
1461            // either — swapping in different execution args, or a different
1462            // injected instruction — must invalidate the signature.
1463            (
1464                "modified_args_json",
1465                Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
1466            ),
1467            ("injected_context", Value::String("do EVIL".to_owned())),
1468            (
1469                "sandbox_mode",
1470                Value::String("danger-full-access".to_owned()),
1471            ),
1472            ("conversation_id", Value::String("conv-OTHER".to_owned())),
1473            ("nonce", Value::String("nonce-OTHER".to_owned())),
1474        ] {
1475            let mut v: Value = serde_json::from_slice(&payload).unwrap();
1476            v[field] = val;
1477            assert!(
1478                verify_signed_response(&v.to_string().into_bytes()).is_none(),
1479                "tampering with {field} must fail verification"
1480            );
1481        }
1482    }
1483
1484    /// #67 gate A: an approver can approve with EDITED args. The signed response
1485    /// carries both the model's PROPOSED args (`args_json`, the identity binding)
1486    /// and the approver's EDIT (`modified_args_json`, what executes). Both are
1487    /// covered by the signature. Crucially, the identity binding
1488    /// ([`VerifiedResponse::authorizes_call`]) still matches the PROPOSED args —
1489    /// so a model that re-emits a different call on resume cannot inherit the
1490    /// approval — while the edit is a separate signed field the executor
1491    /// substitutes. The pure resolver (`polyc_agent::resolve_approved_call`) owns
1492    /// the "empty edit ⇒ run proposed" defaulting; here we only pin the crypto
1493    /// contract: both fields round-trip, and identity binds the proposed args.
1494    #[test]
1495    fn edited_response_binds_proposed_and_carries_modified() {
1496        let signer = ApprovalSigner::from_seed(7);
1497        let proposed = r#"{"path":"/etc/shadow"}"#;
1498        let edited = r#"{"path":"/etc/hostname"}"#;
1499        let (payload, _sig, _pk) = response_payload(
1500            "call-1",
1501            "read_file",
1502            proposed,
1503            edited,
1504            true,
1505            false,
1506            &[],
1507            "slack:T1:U9",
1508            "workspace-write",
1509            "narrowed the path",
1510            "",
1511            "conv-1",
1512            "nonce-1",
1513            &signer,
1514        );
1515        let v = verify_signed_response(&payload).expect("edited approval verifies");
1516        assert_eq!(v.args_json, proposed, "identity binds the proposed args");
1517        assert_eq!(
1518            v.modified_args_json, edited,
1519            "the edit is carried and signed"
1520        );
1521        // Identity binding is against the PROPOSED args — this is what the model
1522        // must re-present on resume; the edit is not part of the identity.
1523        assert!(
1524            v.authorizes_call("call-1", "read_file", proposed),
1525            "the exact proposed call is authorized"
1526        );
1527        assert!(
1528            !v.authorizes_call("call-1", "read_file", edited),
1529            "the edited args are NOT the identity — authorizes_call binds proposed"
1530        );
1531    }
1532
1533    /// #67 gate A: an unedited approval carries an empty `modified_args_json` and
1534    /// still authorizes exactly the proposed call — behaviourally identical to the
1535    /// pre-#67 approve path, so the common case is unchanged.
1536    #[test]
1537    fn unedited_response_carries_empty_edit() {
1538        let signer = ApprovalSigner::from_seed(7);
1539        let proposed = r#"{"path":"/tmp/x"}"#;
1540        let (payload, _sig, _pk) = response_payload(
1541            "call-1",
1542            "read_file",
1543            proposed,
1544            "",
1545            true,
1546            false,
1547            &[],
1548            "slack:T1:U9",
1549            "workspace-write",
1550            "ok",
1551            "",
1552            "conv-1",
1553            "nonce-1",
1554            &signer,
1555        );
1556        let v = verify_signed_response(&payload).expect("verifies");
1557        assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
1558        assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
1559        assert!(v.authorizes_call("call-1", "read_file", proposed));
1560    }
1561
1562    /// #67 gate A: an approver can attach context to inject before the tool runs.
1563    /// The injected context is a signed field that round-trips.
1564    #[test]
1565    fn injected_context_round_trips_and_is_signed() {
1566        let signer = ApprovalSigner::from_seed(7);
1567        let (payload, _sig, _pk) = response_payload(
1568            "call-1",
1569            "shell",
1570            r#"{"cmd":"ls"}"#,
1571            "",
1572            true,
1573            false,
1574            &[],
1575            "slack:T1:U9",
1576            "workspace-write",
1577            "ok",
1578            "only touch files under src/",
1579            "conv-1",
1580            "nonce-1",
1581            &signer,
1582        );
1583        let v = verify_signed_response(&payload).expect("verifies");
1584        assert_eq!(v.injected_context, "only touch files under src/");
1585    }
1586
1587    /// #67 (#539/#540): a dispatch-mutation record round-trips and verifies;
1588    /// tampering with any covered field — including the kind — fails.
1589    #[test]
1590    fn mutation_round_trips_and_tamper_fails() {
1591        let signer = ApprovalSigner::from_seed(7);
1592        let (payload, _s, _p) = mutation_payload(
1593            "tool_input_rewrite",
1594            "call-1",
1595            "shell",
1596            "conv-1",
1597            r#"{"cmd":"rm -rf /"}"#,
1598            r#"{"cmd":"rm /tmp/x"}"#,
1599            &signer,
1600        );
1601        assert_eq!(
1602            verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
1603            Some((
1604                "tool_input_rewrite".to_owned(),
1605                r#"{"cmd":"rm -rf /"}"#.to_owned(),
1606                r#"{"cmd":"rm /tmp/x"}"#.to_owned()
1607            ))
1608        );
1609        for field in [
1610            "kind",
1611            "tool_call_id",
1612            "tool_name",
1613            "conversation_id",
1614            "before",
1615            "after",
1616        ] {
1617            let mut v: Value = serde_json::from_slice(&payload).unwrap();
1618            v[field] = Value::String("EVIL".to_owned());
1619            assert!(
1620                verify_mutation(&v.to_string().into_bytes()).is_none(),
1621                "tampering with {field} must fail"
1622            );
1623        }
1624    }
1625
1626    /// #67 (#538): a deferred "send back" round-trips and verifies; tampering
1627    /// with any covered field fails.
1628    #[test]
1629    fn deferred_round_trips_and_tamper_fails() {
1630        let signer = ApprovalSigner::from_seed(7);
1631        let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
1632        assert_eq!(
1633            verify_deferred(&payload),
1634            Some((
1635                "call-1".to_owned(),
1636                "conv-1".to_owned(),
1637                "need more info".to_owned()
1638            ))
1639        );
1640        for field in ["request_id", "conversation_id", "reason"] {
1641            let mut v: Value = serde_json::from_slice(&payload).unwrap();
1642            v[field] = Value::String("EVIL".to_owned());
1643            assert!(
1644                verify_deferred(&v.to_string().into_bytes()).is_none(),
1645                "tampering with {field} must fail"
1646            );
1647        }
1648    }
1649
1650    #[test]
1651    fn wire_verification_round_trips_and_binds_session_and_caller() {
1652        let signer = ApprovalSigner::from_seed(7);
1653        let (payload, _sig, _pk) = response_payload(
1654            "req-x",
1655            "grep",
1656            r#"{"p":"x"}"#,
1657            "",
1658            true,
1659            true,
1660            &[],
1661            "slack:T1:U9",
1662            "workspace-write",
1663            "go",
1664            "",
1665            "conv-7",
1666            "nonce-7",
1667            &signer,
1668        );
1669        let d = decode_response_full(&payload).expect("decoded payload");
1670        // decode_response_full surfaces every signed field.
1671        assert!(d.approved_for_session);
1672        assert_eq!(d.caller, "slack:T1:U9");
1673        assert_eq!(d.sandbox_mode, "workspace-write");
1674        assert_eq!(d.conversation_id, "conv-7");
1675        assert_eq!(d.nonce, "nonce-7");
1676        assert!(verify_wire_response(
1677            &d.request_id,
1678            &d.tool_name,
1679            &d.args_json,
1680            "",
1681            d.approved,
1682            d.approved_for_session,
1683            &[],
1684            &d.caller,
1685            &d.sandbox_mode,
1686            &d.reason,
1687            "",
1688            &d.conversation_id,
1689            &d.nonce,
1690            &d.signer_pk_hex,
1691            &d.signature_hex
1692        ));
1693        // Re-scoping the remembered grant to a different caller over the wire
1694        // must fail — the caller is covered by the signature.
1695        assert!(!verify_wire_response(
1696            &d.request_id,
1697            &d.tool_name,
1698            &d.args_json,
1699            "",
1700            d.approved,
1701            d.approved_for_session,
1702            &[],
1703            "slack:T1:ATTACKER",
1704            &d.sandbox_mode,
1705            &d.reason,
1706            "",
1707            &d.conversation_id,
1708            &d.nonce,
1709            &d.signer_pk_hex,
1710            &d.signature_hex
1711        ));
1712        // Tampering with the bound args over the wire invalidates the sig.
1713        assert!(!verify_wire_response(
1714            &d.request_id,
1715            &d.tool_name,
1716            r#"{"p":"EVIL"}"#,
1717            "",
1718            d.approved,
1719            d.approved_for_session,
1720            &[],
1721            &d.caller,
1722            &d.sandbox_mode,
1723            &d.reason,
1724            "",
1725            &d.conversation_id,
1726            &d.nonce,
1727            &d.signer_pk_hex,
1728            &d.signature_hex
1729        ));
1730        // Replaying the token into a DIFFERENT conversation over the wire must
1731        // fail — `conversation_id` is covered by the signature (#370, #77 3B).
1732        assert!(!verify_wire_response(
1733            &d.request_id,
1734            &d.tool_name,
1735            &d.args_json,
1736            "",
1737            d.approved,
1738            d.approved_for_session,
1739            &[],
1740            &d.caller,
1741            &d.sandbox_mode,
1742            &d.reason,
1743            "",
1744            "conv-OTHER",
1745            &d.nonce,
1746            &d.signer_pk_hex,
1747            &d.signature_hex
1748        ));
1749    }
1750
1751    /// `#377` core invariant: a reviewer auto-approval signs a payload that is
1752    /// BYTE-IDENTICAL to the one a human signs for the same decision, yet is
1753    /// distinguishable in the audit log by its signed `reason`.
1754    ///
1755    /// There is a single signing function ([`response_payload`]); the reviewer
1756    /// path is just that function with `approved == true`,
1757    /// `approved_for_session == false`, and an auto-review `reason`. We pin two
1758    /// properties:
1759    ///   1. With every argument INCLUDING the reason held equal, the auto and
1760    ///      human calls produce identical bytes + signature — proving the auto
1761    ///      path adds no hidden field and shares the human signing contract
1762    ///      exactly (guards against a future forked auto-signer).
1763    ///   2. With the auto-review reason, the response still verifies, still
1764    ///      carries `approved && !approved_for_session`, and is flagged by
1765    ///      [`is_auto_review_reason`] while a human reason is not — so the event
1766    ///      log can tell machine from human consent off the signed field alone.
1767    ///   3. Substitution resistance: flipping ANY one bound field — the tool, the
1768    ///      args, the beneficiary caller, or the request id — changes both the
1769    ///      canonical bytes AND the signature, so a forged or substituted
1770    ///      approval (signed for one call, replayed to authorize another) cannot
1771    ///      match. Without this, (1)'s byte-identical property would be vacuous.
1772    #[test]
1773    fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
1774        let signer = ApprovalSigner::from_seed(7);
1775        let (rid, tool, args, caller, mode, conv, nonce) = (
1776            "req-9",
1777            "file_read",
1778            r#"{"path":"a.txt"}"#,
1779            "slack:T1:U9",
1780            "read-only",
1781            "conv-9",
1782            "nonce-9",
1783        );
1784
1785        // (1) Same decision + same reason via the one signing path ⇒ identical
1786        // bytes regardless of which side "produced" it. The reviewer is not a
1787        // separate signer; it cannot diverge structurally from the human path.
1788        let shared_reason = auto_review_reason("low");
1789        let human_like = response_payload(
1790            rid,
1791            tool,
1792            args,
1793            "",
1794            true,
1795            false,
1796            &[],
1797            caller,
1798            mode,
1799            &shared_reason,
1800            "",
1801            conv,
1802            nonce,
1803            &signer,
1804        );
1805        let reviewer = response_payload(
1806            rid,
1807            tool,
1808            args,
1809            "",
1810            true,
1811            false,
1812            &[],
1813            caller,
1814            mode,
1815            &shared_reason,
1816            "",
1817            conv,
1818            nonce,
1819            &signer,
1820        );
1821        assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
1822        assert_eq!(human_like.1, reviewer.1, "signature must be identical");
1823
1824        // (2) The auto-review response verifies and is an approve-once decision.
1825        let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
1826        assert!(verified.approved);
1827        assert!(
1828            !verified.approved_for_session,
1829            "a machine decision is never remembered per-caller"
1830        );
1831        assert!(
1832            is_auto_review_reason(&verified.reason),
1833            "the signed reason marks this as an auto-approval"
1834        );
1835
1836        // A genuine human approval over the same call is NOT flagged as auto —
1837        // the distinguisher reads the signed reason, so it is unforgeable.
1838        let (human_payload, _s, _p) = response_payload(
1839            rid,
1840            tool,
1841            args,
1842            "",
1843            true,
1844            false,
1845            &[],
1846            caller,
1847            mode,
1848            "looks fine",
1849            "",
1850            conv,
1851            nonce,
1852            &signer,
1853        );
1854        let human = verify_signed_response(&human_payload).expect("human verifies");
1855        assert!(!is_auto_review_reason(&human.reason));
1856
1857        // The two payloads differ ONLY in the reason field — every bound
1858        // identity / decision / scope field is byte-equal, which is what makes
1859        // the auto path indistinguishable from a human one except by reason.
1860        let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
1861        let h: Value = serde_json::from_slice(&human_payload).unwrap();
1862        for field in [
1863            "request_id",
1864            "tool_name",
1865            "args_json",
1866            "modified_args_json",
1867            "approved",
1868            "approved_for_session",
1869            "caller",
1870            "sandbox_mode",
1871            "injected_context",
1872            "conversation_id",
1873            "nonce",
1874        ] {
1875            assert_eq!(a[field], h[field], "{field} must match the human payload");
1876        }
1877        assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
1878
1879        // (3) Substitution resistance: each variant holds every input equal to
1880        // `reviewer` and flips exactly ONE bound field. A different tool / args /
1881        // caller / request_id / conversation / nonce must change BOTH the
1882        // canonical bytes and the signature — so an approval signed for
1883        // `(req-9, file_read, a.txt, U9, conv-9, nonce-9)` can never be replayed to
1884        // authorize a write, a different path, a different beneficiary, a
1885        // different CONVERSATION (#370, #77 3B), or re-presented under a new nonce.
1886        // This is what makes (1)'s "byte-identical for identical inputs" a
1887        // security property and not just determinism.
1888        let base = &reviewer.0;
1889        let base_sig = &reviewer.1;
1890        for (label, variant) in [
1891            (
1892                "tool",
1893                response_payload(
1894                    rid,
1895                    "file_write",
1896                    args,
1897                    "",
1898                    true,
1899                    false,
1900                    &[],
1901                    caller,
1902                    mode,
1903                    &shared_reason,
1904                    "",
1905                    conv,
1906                    nonce,
1907                    &signer,
1908                ),
1909            ),
1910            (
1911                "args",
1912                response_payload(
1913                    rid,
1914                    tool,
1915                    r#"{"path":"b.txt"}"#,
1916                    "",
1917                    true,
1918                    false,
1919                    &[],
1920                    caller,
1921                    mode,
1922                    &shared_reason,
1923                    "",
1924                    conv,
1925                    nonce,
1926                    &signer,
1927                ),
1928            ),
1929            (
1930                "caller",
1931                response_payload(
1932                    rid,
1933                    tool,
1934                    args,
1935                    "",
1936                    true,
1937                    false,
1938                    &[],
1939                    "slack:T1:UEVIL",
1940                    mode,
1941                    &shared_reason,
1942                    "",
1943                    conv,
1944                    nonce,
1945                    &signer,
1946                ),
1947            ),
1948            (
1949                "request_id",
1950                response_payload(
1951                    "req-OTHER",
1952                    tool,
1953                    args,
1954                    "",
1955                    true,
1956                    false,
1957                    &[],
1958                    caller,
1959                    mode,
1960                    &shared_reason,
1961                    "",
1962                    conv,
1963                    nonce,
1964                    &signer,
1965                ),
1966            ),
1967            (
1968                "conversation_id",
1969                response_payload(
1970                    rid,
1971                    tool,
1972                    args,
1973                    "",
1974                    true,
1975                    false,
1976                    &[],
1977                    caller,
1978                    mode,
1979                    &shared_reason,
1980                    "",
1981                    "conv-OTHER",
1982                    nonce,
1983                    &signer,
1984                ),
1985            ),
1986            (
1987                "nonce",
1988                response_payload(
1989                    rid,
1990                    tool,
1991                    args,
1992                    "",
1993                    true,
1994                    false,
1995                    &[],
1996                    caller,
1997                    mode,
1998                    &shared_reason,
1999                    "",
2000                    conv,
2001                    "nonce-OTHER",
2002                    &signer,
2003                ),
2004            ),
2005        ] {
2006            assert_ne!(
2007                &variant.0, base,
2008                "{label}: a different {label} must change the canonical bytes"
2009            );
2010            assert_ne!(
2011                &variant.1, base_sig,
2012                "{label}: a different {label} must change the signature"
2013            );
2014        }
2015    }
2016
2017    /// `#370` (a): a capability token signed for one conversation is REJECTED
2018    /// when presented for another. The `conversation_id` is covered by the
2019    /// signature, so it cannot be re-targeted without invalidating it; the
2020    /// consume gate ([`verify_capability`]) refuses any token whose signed
2021    /// conversation does not match the one it is being consumed in. Closes `#77`
2022    /// bug 3B (one signed response valid across conversations sharing a
2023    /// `request_id`).
2024    #[test]
2025    fn capability_rejected_across_conversations() {
2026        let signer = ApprovalSigner::from_seed(11);
2027        let (payload, _sig, _pk) = response_payload(
2028            "call-0",
2029            "delete_file",
2030            r#"{"path":"/etc/hosts"}"#,
2031            "",
2032            true,
2033            false,
2034            &[],
2035            "slack:T1:U9",
2036            "workspace-write",
2037            "ok",
2038            "",
2039            "conv-A",
2040            "nonce-A",
2041            &signer,
2042        );
2043        let consumed = HashSet::new();
2044        // Same conversation, fresh nonce ⇒ honored.
2045        assert!(
2046            verify_capability(&payload, "conv-A", &consumed).is_some(),
2047            "a token must verify in the conversation it was signed for"
2048        );
2049        // Different conversation ⇒ rejected, even though request_id/tool/args
2050        // are byte-identical (the #77 3B replay).
2051        assert!(
2052            verify_capability(&payload, "conv-B", &consumed).is_none(),
2053            "a token signed for conv-A must be rejected when consumed in conv-B"
2054        );
2055    }
2056
2057    /// `#370` (b): a single-use token is REJECTED on a second presentation. The
2058    /// consumer records the token's `nonce` after honoring it; a re-presentation
2059    /// of the SAME signed bytes (a captured/replayed token) is then refused.
2060    #[test]
2061    fn capability_is_single_use() {
2062        let signer = ApprovalSigner::from_seed(11);
2063        let (payload, _sig, _pk) = response_payload(
2064            "call-0",
2065            "delete_file",
2066            r#"{"path":"/etc/hosts"}"#,
2067            "",
2068            true,
2069            false,
2070            &[],
2071            "slack:T1:U9",
2072            "workspace-write",
2073            "ok",
2074            "",
2075            "conv-A",
2076            "nonce-A",
2077            &signer,
2078        );
2079        let mut consumed = HashSet::new();
2080        // First presentation is honored and yields the bound nonce.
2081        let v = verify_capability(&payload, "conv-A", &consumed).expect("first use honored");
2082        assert_eq!(v.nonce, "nonce-A");
2083        consumed.insert(v.nonce.clone());
2084        // Second presentation of the same token is rejected — single-use.
2085        assert!(
2086            verify_capability(&payload, "conv-A", &consumed).is_none(),
2087            "a spent token must be rejected on re-presentation"
2088        );
2089    }
2090
2091    /// `#370` (c)/(d): the token is ARGS-BOUND. A verified, approved token
2092    /// authorizes ONLY the exact `(request_id, tool_name, args_json)` it was
2093    /// signed for; a call with different args (a captured approval reused with a
2094    /// new payload) is NOT authorized, while the matching call IS.
2095    #[test]
2096    fn capability_authorizes_only_matching_args() {
2097        let signer = ApprovalSigner::from_seed(11);
2098        let (payload, _sig, _pk) = response_payload(
2099            "call-0",
2100            "delete_file",
2101            r#"{"path":"/tmp/scratch"}"#,
2102            "",
2103            true,
2104            false,
2105            &[],
2106            "slack:T1:U9",
2107            "workspace-write",
2108            "ok",
2109            "",
2110            "conv-A",
2111            "nonce-A",
2112            &signer,
2113        );
2114        let consumed = HashSet::new();
2115        let v = verify_capability(&payload, "conv-A", &consumed).expect("verifies in conv-A");
2116        // (c) different args ⇒ NOT authorized.
2117        assert!(
2118            !v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
2119            "a token must not authorize a call with different args"
2120        );
2121        // …nor a different tool with the same id.
2122        assert!(
2123            !v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
2124            "a token must not authorize a different tool"
2125        );
2126        // (d) the exact signed call ⇒ authorized (happy path).
2127        assert!(
2128            v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
2129            "the exact signed call must be authorized"
2130        );
2131    }
2132
2133    /// A denial never authorizes a call, regardless of identity match.
2134    #[test]
2135    fn denied_capability_authorizes_nothing() {
2136        let signer = ApprovalSigner::from_seed(11);
2137        let (payload, _sig, _pk) = response_payload(
2138            "call-0",
2139            "delete_file",
2140            "{}",
2141            "",
2142            false,
2143            false,
2144            &[],
2145            "slack:T1:U9",
2146            "workspace-write",
2147            "deny",
2148            "",
2149            "conv-A",
2150            "nonce-A",
2151            &signer,
2152        );
2153        let consumed = HashSet::new();
2154        let v = verify_capability(&payload, "conv-A", &consumed).expect("verifies");
2155        assert!(!v.approved);
2156        assert!(
2157            !v.authorizes_call("call-0", "delete_file", "{}"),
2158            "a denied token authorizes nothing even on an exact identity match"
2159        );
2160    }
2161
2162    #[test]
2163    fn request_payload_round_trips_id() {
2164        let bytes = request_payload(
2165            "call-7",
2166            "rm",
2167            r#"{"path":"/etc"}"#,
2168            "workspace-write",
2169            "",
2170            &[],
2171        );
2172        assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
2173        // An ordinary gated call carries no override reason.
2174        assert_eq!(decode_request_reason(&bytes), "");
2175    }
2176
2177    #[test]
2178    fn request_payload_carries_override_reason() {
2179        // The lethal-trifecta override reason rides the durable approval_request
2180        // so the signed log records WHY a trifecta-gated call was paused.
2181        let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
2182        let bytes = request_payload(
2183            "call-9",
2184            "web_fetch",
2185            r#"{"url":"https://x"}"#,
2186            "",
2187            reason,
2188            &[],
2189        );
2190        assert_eq!(decode_request_reason(&bytes), reason);
2191        // The existing scalar fields still decode unchanged.
2192        assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
2193        assert_eq!(
2194            decode_request_fields(&bytes),
2195            Some((
2196                "call-9".to_owned(),
2197                "web_fetch".to_owned(),
2198                r#"{"url":"https://x"}"#.to_owned()
2199            ))
2200        );
2201    }
2202
2203    /// Golden test: `receipt_payload` must produce the EXACT **v2** signed-payload
2204    /// bytes — `version` + the six settlement facts + the four binding fields,
2205    /// in that order — plus the two uncovered signature fields. We recompute the
2206    /// expected canonical+full JSON inline and assert the struct form is
2207    /// byte-identical, pinning the v2 key set, order, and signature so a future
2208    /// edit cannot silently reorder, rename, or swap a covered field. Recomputing
2209    /// inline (rather than hardcoding bytes) keeps the golden stable across
2210    /// `serde_json` feature unification (e.g. `preserve_order`), which only flips
2211    /// key order — what matters is that both forms agree under whatever ordering
2212    /// is in effect.
2213    #[test]
2214    fn receipt_payload_pins_v2_canonical_shape() {
2215        let signer = ApprovalSigner::from_seed(99);
2216        let (reference, amount, currency, recipient, method, timestamp) = (
2217            "tx-abc",
2218            "0.01",
2219            "USDC",
2220            "0xrecipient",
2221            "tempo",
2222            "2026-06-02T00:00:00Z",
2223        );
2224        let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
2225            "outbound_payment_receipt",
2226            "call-1",
2227            "42",
2228            "abcd1234",
2229            "conv-xyz",
2230        );
2231
2232        // The exact v2 JSON the implementation must build, recomputed here.
2233        let expected_canonical = serde_json::json!({
2234            "version": RECEIPT_VERSION,
2235            "kind": kind,
2236            "reference": reference,
2237            "amount": amount,
2238            "currency": currency,
2239            "recipient": recipient,
2240            "method": method,
2241            "timestamp": timestamp,
2242            "tool_call_id": tool_call_id,
2243            "approval_pos": approval_pos,
2244            "approved_args_hash": approved_args_hash,
2245            "subject": subject,
2246        });
2247        let expected_sig = signer.sign(expected_canonical.to_string().as_bytes());
2248        let expected_pk = signer.public_key_bytes();
2249        let expected_full = serde_json::json!({
2250            "version": RECEIPT_VERSION,
2251            "kind": kind,
2252            "reference": reference,
2253            "amount": amount,
2254            "currency": currency,
2255            "recipient": recipient,
2256            "method": method,
2257            "timestamp": timestamp,
2258            "tool_call_id": tool_call_id,
2259            "approval_pos": approval_pos,
2260            "approved_args_hash": approved_args_hash,
2261            "subject": subject,
2262            "signed_by": hex_lower(&expected_pk),
2263            "signature_hex": hex_lower(&expected_sig),
2264        })
2265        .to_string();
2266
2267        let (payload, sig, pk) = receipt_payload(
2268            &ReceiptPayload {
2269                kind,
2270                reference,
2271                amount,
2272                currency,
2273                recipient,
2274                method,
2275                timestamp,
2276                tool_call_id,
2277                approval_pos,
2278                approved_args_hash,
2279                subject,
2280            },
2281            &signer,
2282        );
2283
2284        assert_eq!(
2285            String::from_utf8(payload).unwrap(),
2286            expected_full,
2287            "v2 receipt payload must be byte-identical to the pinned v2 shape"
2288        );
2289        assert_eq!(
2290            sig, expected_sig,
2291            "signature must match the pinned v2 shape"
2292        );
2293        assert_eq!(pk, expected_pk, "public key must be unchanged");
2294    }
2295
2296    /// Single-source guard: both the signing path (`receipt_payload`) and the
2297    /// verifying path (`verify_signed_receipt`) MUST derive their canonical
2298    /// signed JSON from the one [`ReceiptPayload::canonical_json`] builder, so
2299    /// the signed field set/order cannot drift between sign and verify.
2300    ///
2301    /// We assert the canonical bytes the signer commits to are exactly the
2302    /// bytes `canonical_json` produces for the same fields, and that a receipt
2303    /// reconstructed from `VerifiedReceipt` (the verify path's owned form)
2304    /// yields the identical canonical bytes. If a future edit added a field to
2305    /// one json! block but not the other, those bytes would differ and this
2306    /// (plus the round-trip) would fail.
2307    #[test]
2308    fn receipt_sign_and_verify_share_one_canonical_source() {
2309        let signer = ApprovalSigner::from_seed(99);
2310        let fields = ReceiptPayload {
2311            kind: "outbound_payment_receipt",
2312            reference: "tx-abc",
2313            amount: "0.01",
2314            currency: "USDC",
2315            recipient: "0xrecipient",
2316            method: "tempo",
2317            timestamp: "2026-06-02T00:00:00Z",
2318            tool_call_id: "call-1",
2319            approval_pos: "42",
2320            approved_args_hash: "abcd1234",
2321            subject: "conv-xyz",
2322        };
2323
2324        // The signed bytes the producer commits to.
2325        let canonical_bytes = fields.canonical_json().to_string().into_bytes();
2326        let expected_sig = signer.sign(&canonical_bytes);
2327        let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
2328        assert_eq!(
2329            sig, expected_sig,
2330            "receipt_payload must sign exactly ReceiptPayload::canonical_json"
2331        );
2332
2333        // The verify path reconstructs the same canonical bytes from its owned
2334        // form before checking the signature.
2335        let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
2336        let verified = verify_signed_receipt(&payload).expect("verifies");
2337        let verified_canonical = ReceiptPayload {
2338            kind: &verified.kind,
2339            reference: &verified.reference,
2340            amount: &verified.amount,
2341            currency: &verified.currency,
2342            recipient: &verified.recipient,
2343            method: &verified.method,
2344            timestamp: &verified.timestamp,
2345            tool_call_id: &verified.tool_call_id,
2346            approval_pos: &verified.approval_pos,
2347            approved_args_hash: &verified.approved_args_hash,
2348            subject: &verified.subject,
2349        }
2350        .canonical_json()
2351        .to_string()
2352        .into_bytes();
2353        assert_eq!(
2354            verified_canonical, canonical_bytes,
2355            "verify path must derive canonical JSON from the same single source"
2356        );
2357    }
2358
2359    #[test]
2360    fn crypto_receipt_payload_signs_and_verifies() {
2361        let signer = ApprovalSigner::from_seed(99);
2362        let (payload, _sig, _pk) = receipt_payload(
2363            &ReceiptPayload {
2364                kind: "outbound_payment_receipt",
2365                reference: "tx-abc",
2366                amount: "0.01",
2367                currency: "USDC",
2368                recipient: "0xrecipient",
2369                method: "tempo",
2370                timestamp: "2026-06-02T00:00:00Z",
2371                tool_call_id: "call-1",
2372                approval_pos: "42",
2373                approved_args_hash: "abcd1234",
2374                subject: "conv-xyz",
2375            },
2376            &signer,
2377        );
2378        let verified =
2379            verify_signed_receipt(&payload).expect("signature verifies on untampered receipt");
2380        assert_eq!(verified.reference, "tx-abc");
2381        assert_eq!(verified.amount, "0.01");
2382        assert_eq!(verified.currency, "USDC");
2383        assert_eq!(verified.recipient, "0xrecipient");
2384        assert_eq!(verified.method, "tempo");
2385        assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
2386        // The v2 kind + binding tuple + opaque subject round-trip and are
2387        // covered by the signature.
2388        assert_eq!(verified.version, RECEIPT_VERSION);
2389        assert_eq!(verified.kind, "outbound_payment_receipt");
2390        assert_eq!(verified.tool_call_id, "call-1");
2391        assert_eq!(verified.approval_pos, "42");
2392        assert_eq!(verified.approved_args_hash, "abcd1234");
2393        assert_eq!(verified.subject, "conv-xyz");
2394    }
2395
2396    #[test]
2397    fn tampered_receipt_fails_verification() {
2398        let signer = ApprovalSigner::from_seed(99);
2399        let (payload, _sig, _pk) = receipt_payload(
2400            &ReceiptPayload {
2401                kind: "outbound_payment_receipt",
2402                reference: "tx-abc",
2403                amount: "0.01",
2404                currency: "USDC",
2405                recipient: "0xrecipient",
2406                method: "tempo",
2407                timestamp: "2026-06-02T00:00:00Z",
2408                tool_call_id: "call-1",
2409                approval_pos: "42",
2410                approved_args_hash: "abcd1234",
2411                subject: "conv-xyz",
2412            },
2413            &signer,
2414        );
2415        // Tamper with a covered field (amount).
2416        let mut v: Value = serde_json::from_slice(&payload).unwrap();
2417        v["amount"] = Value::String("9999.00".to_owned());
2418        let tampered = v.to_string().into_bytes();
2419        assert!(verify_signed_receipt(&tampered).is_none());
2420    }
2421
2422    #[test]
2423    fn tampered_receipt_binding_field_fails_verification() {
2424        let signer = ApprovalSigner::from_seed(99);
2425        let (payload, _sig, _pk) = receipt_payload(
2426            &ReceiptPayload {
2427                kind: "outbound_payment_receipt",
2428                reference: "tx-abc",
2429                amount: "0.01",
2430                currency: "USDC",
2431                recipient: "0xrecipient",
2432                method: "tempo",
2433                timestamp: "2026-06-02T00:00:00Z",
2434                tool_call_id: "call-1",
2435                approval_pos: "42",
2436                approved_args_hash: "abcd1234",
2437                subject: "conv-xyz",
2438            },
2439            &signer,
2440        );
2441        // Re-pointing the receipt at a different approval position invalidates
2442        // the signature — the binding tuple is covered, not advisory.
2443        let mut v: Value = serde_json::from_slice(&payload).unwrap();
2444        v["approval_pos"] = Value::String("7".to_owned());
2445        let tampered = v.to_string().into_bytes();
2446        assert!(verify_signed_receipt(&tampered).is_none());
2447
2448        // Re-filing the payload under the other direction's kind likewise
2449        // fails — the signed `kind` is what makes direction trustworthy
2450        // independent of the (unsigned) stored event kind.
2451        let mut v: Value = serde_json::from_slice(&payload).unwrap();
2452        v["kind"] = Value::String("payment_receipt".to_owned());
2453        let refiled = v.to_string().into_bytes();
2454        assert!(verify_signed_receipt(&refiled).is_none());
2455    }
2456
2457    /// A receipt persisted before the v2 binding (no `version`, six fields only)
2458    /// must still verify for forensics, surfacing as `version == 1` with empty
2459    /// binding fields. Mirrors the legacy approval-response path.
2460    #[test]
2461    fn legacy_v1_receipt_still_verifies() {
2462        let signer = ApprovalSigner::from_seed(99);
2463        let canonical = serde_json::json!({
2464            "reference": "tx-old",
2465            "amount": "0.02",
2466            "currency": "USDC",
2467            "recipient": "0xr",
2468            "method": "tempo",
2469            "timestamp": "2026-06-01T00:00:00Z",
2470        });
2471        let sig = signer.sign(canonical.to_string().as_bytes());
2472        let pk = signer.public_key_bytes();
2473        let v1 = serde_json::json!({
2474            "reference": "tx-old",
2475            "amount": "0.02",
2476            "currency": "USDC",
2477            "recipient": "0xr",
2478            "method": "tempo",
2479            "timestamp": "2026-06-01T00:00:00Z",
2480            "signed_by": hex_lower(&pk),
2481            "signature_hex": hex_lower(&sig),
2482        })
2483        .to_string()
2484        .into_bytes();
2485
2486        let verified = verify_signed_receipt(&v1).expect("a valid v1 receipt still verifies");
2487        assert_eq!(verified.version, 1);
2488        assert_eq!(verified.reference, "tx-old");
2489        assert!(verified.kind.is_empty());
2490        assert!(verified.tool_call_id.is_empty());
2491        assert!(verified.approval_pos.is_empty());
2492        assert!(verified.subject.is_empty());
2493    }
2494
2495    /// Injecting a `version` key into a validly-signed legacy receipt must not
2496    /// verify: the v1 canonical does not cover `version`, so dispatching the
2497    /// claimed version to the v1 canonical would let the signature check pass
2498    /// while `VerifiedReceipt.version` echoed an unsigned, writer-chosen value.
2499    /// Only an absent `version` (⇒ 1) or the exact current version is accepted.
2500    #[test]
2501    fn injected_version_on_v1_signed_receipt_fails() {
2502        let signer = ApprovalSigner::from_seed(99);
2503        let canonical = serde_json::json!({
2504            "reference": "tx-old",
2505            "amount": "0.02",
2506            "currency": "USDC",
2507            "recipient": "0xr",
2508            "method": "tempo",
2509            "timestamp": "2026-06-01T00:00:00Z",
2510        });
2511        let sig = signer.sign(canonical.to_string().as_bytes());
2512        let pk = signer.public_key_bytes();
2513        let mut full = canonical;
2514        full["signed_by"] = Value::String(hex_lower(&pk));
2515        full["signature_hex"] = Value::String(hex_lower(&sig));
2516
2517        // A claimed future version, an explicit "1", and a non-integer are all
2518        // refused outright (fail closed) — never verified against a guessed
2519        // canonical.
2520        for injected in [
2521            Value::from(7_u64),
2522            Value::from(1_u64),
2523            Value::String("2".to_owned()),
2524        ] {
2525            let mut tampered = full.clone();
2526            tampered["version"] = injected;
2527            assert!(
2528                verify_signed_receipt(&tampered.to_string().into_bytes()).is_none(),
2529                "a writer-chosen version key must never verify"
2530            );
2531        }
2532        // Sanity: without the injected key the same payload verifies as v1.
2533        assert!(verify_signed_receipt(&full.to_string().into_bytes()).is_some());
2534    }
2535}