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 ApprovalSigner {
35    /// Build a signer from a deterministic seed.
36    ///
37    /// **Insecure and test-only** (`#784`, `#1171`): this seed is public
38    /// (it's checked into this source file's history), so every signature it
39    /// mints is forgeable by anyone. Reachable only in tests / `test-util`
40    /// builds — gone entirely from a release binary, which must load real
41    /// key material via [`ApprovalSigner::from_key_bytes`] instead. There is
42    /// deliberately no `Default` impl (`#1171`): every call site names its
43    /// seed explicitly, or loads real key material, so a signer's provenance
44    /// is never implicit.
45    #[cfg(any(test, feature = "test-util"))]
46    #[must_use]
47    pub fn from_seed(seed: u64) -> Self {
48        Self {
49            inner: Arc::new(Signer::from_seed(seed)),
50        }
51    }
52
53    /// Build a signer from raw ed25519 private-key bytes loaded from a
54    /// secret store (`#784`) — the ONLY production-safe way to construct an
55    /// [`ApprovalSigner`]. `bytes` must be exactly 32 bytes, the raw encoded
56    /// ed25519 private key.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`crate::SignerError`] if `bytes` is not a validly-encoded
61    /// ed25519 private key.
62    pub fn from_key_bytes(bytes: &[u8]) -> Result<Self, crate::SignerError> {
63        Ok(Self {
64            inner: Arc::new(Signer::from_key_bytes(bytes)?),
65        })
66    }
67
68    /// Encoded public key bytes; clients verify approval signatures against
69    /// these.
70    #[must_use]
71    pub fn public_key_bytes(&self) -> Vec<u8> {
72        self.inner.public_key_bytes()
73    }
74
75    /// Sign the canonical bytes of a response payload. The canonical bytes
76    /// are the JSON encoding with the `signature_hex` and `signed_by` fields
77    /// cleared — same shape as [`crate::toolcall`] (signature commits to
78    /// everything except itself).
79    #[must_use]
80    pub fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
81        self.inner.sign(canonical_bytes)
82    }
83
84    /// Borrow the underlying platform [`Signer`], for the grant-family builders
85    /// that take one directly (e.g. [`crate::grant::suspension_payload`], the
86    /// platform's ed25519 kill switch for a live grant, #623). The suspension is
87    /// signed by the SAME platform key every approval-side signature uses, so a
88    /// suspension and a `grant_replay` in the audit trail share one signer.
89    #[must_use]
90    pub fn as_signer(&self) -> &Signer {
91        &self.inner
92    }
93}
94
95/// JSON payload for an `approval_request` event.
96///
97/// `request_id` is the model's tool-call id — stable for the lifetime of the
98/// turn and used by `ApprovalService::Respond` to address the matching
99/// response.
100/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
101/// harness was running under when it paused this call. The control plane reads it
102/// back at respond time and signs it into the response, so a remembered approval
103/// is bound to the mode it was granted under.
104/// `reason` is the OVERRIDE explanation for why the call is gated — empty for an
105/// ordinary gated call, non-empty only for the lethal-trifecta / Rule-of-Two
106/// containment override. It is presentation + audit (NOT covered by any
107/// signature — the `approval_request` event itself is unsigned; the *response*
108/// is what gets signed), so the durable log records WHY a trifecta-gated call
109/// was paused, and the edge can render it on the approval card.
110/// `missing_capabilities` records the capability shortfall the gate computed
111/// when it paused the call (`#595`): the stable kebab-case names of the
112/// capabilities the call required but was not granted. Read back at respond
113/// time and signed into the response as `covered_capabilities`, so a
114/// remembered grant is scoped to exactly what the approver saw it cover.
115/// Empty for an ordinary policy/sandbox gate.
116#[must_use]
117pub fn request_payload(
118    request_id: &str,
119    tool_name: &str,
120    args_json: &str,
121    sandbox_mode: &str,
122    reason: &str,
123    missing_capabilities: &[String],
124) -> Vec<u8> {
125    serde_json::json!({
126        "tool_name": tool_name,
127        "args_json": args_json,
128        "request_id": request_id,
129        "sandbox_mode": sandbox_mode,
130        "reason": reason,
131        "missing_capabilities": missing_capabilities,
132    })
133    .to_string()
134    .into_bytes()
135}
136
137/// The single source for the signed `approval_response` canonical JSON. Both the
138/// signing path ([`response_payload`]) and the verifying paths
139/// ([`verify_signed_response`], [`verify_wire_response`]) route through this so
140/// the covered field set/order cannot drift. Adding/renaming/reordering here is a
141/// signed-contract change.
142///
143/// The signature binds the approval to the exact call identity (`request_id`,
144/// `tool_name`, `args_json`) so a re-emitted same-id call with different
145/// args/tool cannot inherit it, AND — for a "don't ask again" decision — to
146/// `approved_for_session` plus the `caller` the memory is scoped to, so a
147/// remembered approval is per-caller and unforgeable. A one-shot approval simply
148/// signs `approved_for_session: false`.
149///
150/// `approver` (`#1025`, RFC 8693's `act`/actor claim, distinct from `caller`'s
151/// `sub`/subject) is the identity that actually resolved this decision, when
152/// the edge supplied one — NOT necessarily the same persona as `caller` (the
153/// paused turn's own beneficiary): an admin approving on someone else's
154/// behalf signs a DIFFERENT `approver` than `caller`. Omitted from the
155/// canonical entirely when empty (rather than signed as `""`) so every
156/// `approval_response` persisted before this field existed — and every one
157/// an edge that doesn't yet supply an approver identity signs today — stays
158/// byte-identical and continues to verify unchanged; only a genuinely
159/// non-empty `approver` changes the signed shape, and that decision is
160/// always signed by code that already knows about this field.
161///
162/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
163/// PAUSED turn ran under, resolved server-side from the request. Binding it means
164/// a remembered approval granted under one mode cannot be replayed to auto-
165/// approve a later call running under a different (e.g. more-privileged) mode —
166/// the harness re-prompts. Empty when no mode was recorded.
167///
168/// `conversation_id` and `nonce` make the response a SINGLE-USE, conversation-
169/// bound capability token (`#370`, closes `#77` bug 3B). `conversation_id` binds
170/// the approval to the one conversation it was granted in, so a signed response
171/// copied into a different conversation's log fails to verify against that
172/// conversation. `nonce` is a per-approval unique value the consumer records on
173/// use, so a captured token cannot be re-presented after it has been spent. Both
174/// are covered by the signature, so neither can be re-targeted or replayed
175/// without invalidating it.
176/// `covered_capabilities` (`#595`) records the capability shortfall this
177/// approval covered — the missing set the gate computed when it paused the
178/// call. Covered by the signature, so the effective session-grant key is
179/// (caller, tool, covered capabilities): if the tool's required set later
180/// grows, the old grant does not cover the new capability and the gate asks
181/// again. Empty for an approval of an ordinary policy/sandbox gate.
182#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
183fn response_canonical(
184    request_id: &str,
185    tool_name: &str,
186    args_json: &str,
187    modified_args_json: &str,
188    approved: bool,
189    approved_for_session: bool,
190    covered_capabilities: &[String],
191    caller: &str,
192    approver_id: &str,
193    sandbox_mode: &str,
194    reason: &str,
195    injected_context: &str,
196    conversation_id: &str,
197    nonce: &str,
198) -> Vec<u8> {
199    let mut v = serde_json::json!({
200        "request_id": request_id,
201        "tool_name": tool_name,
202        "args_json": args_json,
203        "modified_args_json": modified_args_json,
204        "approved": approved,
205        "approved_for_session": approved_for_session,
206        "covered_capabilities": covered_capabilities,
207        "caller": caller,
208        "sandbox_mode": sandbox_mode,
209        "reason": reason,
210        "injected_context": injected_context,
211        "conversation_id": conversation_id,
212        "nonce": nonce,
213    });
214    // See the doc comment on `response_payload` (#1025): omitted entirely
215    // when empty, not signed as `""`, so this stays byte-identical to every
216    // `approval_response` ever persisted before this field existed.
217    if !approver_id.is_empty() {
218        v["approver"] = serde_json::Value::String(approver_id.to_owned());
219    }
220    v.to_string().into_bytes()
221}
222
223/// JSON payload for an `approval_response` event.
224///
225/// The signature commits to the canonical (unsigned) JSON form: the call
226/// identity (`request_id`, `tool_name`, `args_json`), the decision
227/// (`approved`), the session scope
228/// (`approved_for_session`) and the `caller` it is bound to. `signed_by` and
229/// `signature_hex` are populated *after* the signer runs and are NOT covered by
230/// the signature.
231///
232/// Each parameter is a distinct signed field, so they're passed individually
233/// rather than wrapped in a struct (the canonical form is the contract).
234///
235/// `conversation_id` binds the token to the conversation it was granted in and
236/// `nonce` is a per-approval unique value (the control plane mints a fresh one
237/// per response); together they make the approval a single-use, conversation-
238/// bound capability (`#370`). Returns
239/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
240#[must_use]
241#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
242pub fn response_payload(
243    request_id: &str,
244    tool_name: &str,
245    args_json: &str,
246    modified_args_json: &str,
247    approved: bool,
248    approved_for_session: bool,
249    covered_capabilities: &[String],
250    caller: &str,
251    approver_id: &str,
252    sandbox_mode: &str,
253    reason: &str,
254    injected_context: &str,
255    conversation_id: &str,
256    nonce: &str,
257    signer: &ApprovalSigner,
258) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
259    let canonical_bytes = response_canonical(
260        request_id,
261        tool_name,
262        args_json,
263        modified_args_json,
264        approved,
265        approved_for_session,
266        covered_capabilities,
267        caller,
268        approver_id,
269        sandbox_mode,
270        reason,
271        injected_context,
272        conversation_id,
273        nonce,
274    );
275    let signature = signer.sign(&canonical_bytes);
276    let pk = signer.public_key_bytes();
277    let mut full = serde_json::json!({
278        "request_id": request_id,
279        "tool_name": tool_name,
280        "args_json": args_json,
281        "modified_args_json": modified_args_json,
282        "approved": approved,
283        "approved_for_session": approved_for_session,
284        "covered_capabilities": covered_capabilities,
285        "caller": caller,
286        "sandbox_mode": sandbox_mode,
287        "reason": reason,
288        "injected_context": injected_context,
289        "conversation_id": conversation_id,
290        "nonce": nonce,
291        "signed_by": crate::hex::lower(&pk),
292        "signature_hex": crate::hex::lower(&signature),
293    });
294    // Same omit-when-empty rule as `response_canonical` (#1025) — keeps the
295    // persisted payload byte-identical to before this field existed.
296    if !approver_id.is_empty() {
297        full["approver"] = serde_json::Value::String(approver_id.to_owned());
298    }
299    (full.to_string().into_bytes(), signature, pk)
300}
301
302/// Scope names for a signed taint-excision marker (`#590`).
303///
304/// `cascade` is the sound default: the named positions are excised AND so is
305/// every model-authored content event after the earliest of them — the
306/// recovery literature shows a model re-derives an injected instruction from
307/// its own retained reasoning if only the source is removed. `source-only`
308/// excises exactly the named positions: an explicit, human-vouched override
309/// for content the person read and judged benign, named in the signed
310/// payload so the audit trail shows which posture the human chose.
311pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
312/// See [`EXCISION_SCOPE_CASCADE`].
313pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
314
315/// The canonical (signature-covered) form of a `taint_excision` marker.
316///
317/// Covers the conversation (a marker signed for one conversation cannot be
318/// replayed into another), the scope, the named journal positions, and who
319/// requested the excision — so a marker can be neither forged, re-targeted,
320/// nor widened. `signed_by`/`signature_hex` are appended after signing and
321/// are not covered.
322fn excision_canonical(
323    conversation_id: &str,
324    scope: &str,
325    positions: &[u64],
326    requested_by: &str,
327    reason: &str,
328) -> Vec<u8> {
329    serde_json::json!({
330        "conversation_id": conversation_id,
331        "scope": scope,
332        "positions": positions,
333        "requested_by": requested_by,
334        "reason": reason,
335    })
336    .to_string()
337    .into_bytes()
338}
339
340/// JSON payload for a signed `taint_excision` event (`#590`).
341///
342/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
343#[must_use]
344pub fn excision_payload(
345    conversation_id: &str,
346    scope: &str,
347    positions: &[u64],
348    requested_by: &str,
349    reason: &str,
350    signer: &ApprovalSigner,
351) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
352    let canonical = excision_canonical(conversation_id, scope, positions, requested_by, reason);
353    let signature = signer.sign(&canonical);
354    let pk = signer.public_key_bytes();
355    let full = serde_json::json!({
356        "conversation_id": conversation_id,
357        "scope": scope,
358        "positions": positions,
359        "requested_by": requested_by,
360        "reason": reason,
361        "signed_by": crate::hex::lower(&pk),
362        "signature_hex": crate::hex::lower(&signature),
363    });
364    (full.to_string().into_bytes(), signature, pk)
365}
366
367/// The canonical (signature-covered) form of a `grant_replay` audit record
368/// (`#594`).
369///
370/// Binds the conversation and turn the replay happened in, the tool the grant
371/// cleared, the [`crate::grant::grant_ref`] of the grant that cleared it, the
372/// capability names the grant kept against taint, and the template-coverage hash
373/// (#618) the grant matched — so the durable record commits to exactly which
374/// grant kept which capabilities on which turn. `signed_by`/`signature_hex` are
375/// appended after signing and are not covered.
376fn grant_replay_canonical(
377    conversation_id: &str,
378    turn_id: &str,
379    tool: &str,
380    grant_ref: &str,
381    covered_capabilities: &[String],
382    coverage_hash: &str,
383) -> Vec<u8> {
384    serde_json::json!({
385        "conversation_id": conversation_id,
386        "turn_id": turn_id,
387        "tool": tool,
388        "grant_ref": grant_ref,
389        "covered_capabilities": covered_capabilities,
390        "coverage_hash": coverage_hash,
391    })
392    .to_string()
393    .into_bytes()
394}
395
396/// JSON payload for a signed `grant_replay` audit event (`#594`).
397///
398/// The durable, trust-tagged record PRD §12 requires that a replayed grant kept
399/// a capability taint would have removed — appended by the control plane once per
400/// grant-cleared gate (never a `tracing` line). Platform-signed (ed25519, the
401/// [`ApprovalSigner`]) so the record is tamper-evident and attributable. The
402/// field names match `polychrome.events.v1.GrantReplayEvent`, so the forensics
403/// decoder renders it. Returns `(full_payload_bytes, signature_bytes,
404/// public_key_bytes)`.
405#[must_use]
406#[allow(clippy::too_many_arguments)] // each arg is a distinct field of the audit record
407pub fn grant_replay_payload(
408    conversation_id: &str,
409    turn_id: &str,
410    tool: &str,
411    grant_ref: &str,
412    covered_capabilities: &[String],
413    coverage_hash: &str,
414    signer: &ApprovalSigner,
415) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
416    let canonical = grant_replay_canonical(
417        conversation_id,
418        turn_id,
419        tool,
420        grant_ref,
421        covered_capabilities,
422        coverage_hash,
423    );
424    let signature = signer.sign(&canonical);
425    let pk = signer.public_key_bytes();
426    let full = serde_json::json!({
427        "conversation_id": conversation_id,
428        "turn_id": turn_id,
429        "tool": tool,
430        "grant_ref": grant_ref,
431        "covered_capabilities": covered_capabilities,
432        "coverage_hash": coverage_hash,
433        "signed_by": crate::hex::lower(&pk),
434        "signature_hex": crate::hex::lower(&signature),
435    });
436    (full.to_string().into_bytes(), signature, pk)
437}
438
439/// Verify a persisted `grant_replay` audit payload against its embedded signer
440/// (`#594`).
441///
442/// Rebuilds the canonical from the payload's own fields and checks the embedded
443/// ed25519 signature. Returns `true` only when the signature covers the exact
444/// record — a tamper to any bound field flips it to `false`. Fail-closed on any
445/// malformed field or bad hex.
446#[must_use]
447pub fn verify_grant_replay(payload: &[u8]) -> bool {
448    let Ok(v) = serde_json::from_slice::<serde_json::Value>(payload) else {
449        return false;
450    };
451    let (
452        Some(conversation_id),
453        Some(turn_id),
454        Some(tool),
455        Some(grant_ref),
456        Some(covered),
457        Some(coverage_hash),
458        Some(signed_by),
459        Some(signature_hex),
460    ) = (
461        v.get("conversation_id").and_then(Value::as_str),
462        v.get("turn_id").and_then(Value::as_str),
463        v.get("tool").and_then(Value::as_str),
464        v.get("grant_ref").and_then(Value::as_str),
465        v.get("covered_capabilities").and_then(Value::as_array),
466        v.get("coverage_hash").and_then(Value::as_str),
467        v.get("signed_by").and_then(Value::as_str),
468        v.get("signature_hex").and_then(Value::as_str),
469    )
470    else {
471        return false;
472    };
473    let Some(covered_capabilities) = covered
474        .iter()
475        .map(|c| c.as_str().map(str::to_owned))
476        .collect::<Option<Vec<_>>>()
477    else {
478        return false;
479    };
480    let (Some(pk), Some(sig)) = (
481        crate::hex::decode(signed_by),
482        crate::hex::decode(signature_hex),
483    ) else {
484        return false;
485    };
486    let canonical = grant_replay_canonical(
487        conversation_id,
488        turn_id,
489        tool,
490        grant_ref,
491        &covered_capabilities,
492        coverage_hash,
493    );
494    crate::verify(&pk, &canonical, &sig)
495}
496
497/// Whether `signer_pk` is a member of the deployment's pinned approval
498/// allow-list (`#845`).
499///
500/// An internally-consistent ed25519 signature proves only that a payload was
501/// not altered after it was signed; it says nothing about whether the signer is
502/// one the deployment trusts, since anyone can mint a keypair, embed its own
503/// public key, and self-sign an arbitrary payload. Every trust-gated verifier
504/// therefore checks membership here before honoring a payload. An empty
505/// allow-list trusts no one (fail closed). Shared by the grant-replay and
506/// approval-response pinned verifiers so the allow-list check cannot drift
507/// between them or from [`verify_signed_receipt`]'s.
508fn signer_is_trusted(signer_pk: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
509    trusted_signers.iter().any(|k| k.as_slice() == signer_pk)
510}
511
512/// Verify a persisted `grant_replay` audit payload against a **trusted-signer
513/// allow-list** (`#845`).
514///
515/// Like [`verify_grant_replay`] but additionally rejects any payload whose
516/// embedded `signed_by` key is not one of `trusted_signers` — the deployment's
517/// pinned approval public key(s), typically the running
518/// [`ApprovalSigner::public_key_bytes`], since every `grant_replay` record this
519/// control plane persists is self-signed with it. Without this gate an attacker
520/// could sign a well-formed record with their own key, embed it, and have the
521/// audit trail render it as `valid`. Mirrors [`verify_signed_receipt`]'s
522/// allow-list gate. Returns `true` only when the signer is trusted AND the
523/// signature covers the exact record; `false` (fail closed) on a malformed
524/// payload, an untrusted signer, or a bad signature.
525#[must_use]
526pub fn verify_grant_replay_pinned(payload: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
527    // Gate on the allow-list before the (single-sourced) signature check: an
528    // untrusted signer is rejected no matter how internally consistent its
529    // signature is.
530    let Some(pk) = serde_json::from_slice::<Value>(payload).ok().and_then(|v| {
531        v.get("signed_by")
532            .and_then(Value::as_str)
533            .and_then(crate::hex::decode)
534    }) else {
535        return false;
536    };
537    if !signer_is_trusted(&pk, trusted_signers) {
538        return false;
539    }
540    verify_grant_replay(payload)
541}
542
543/// A verified `taint_excision` marker.
544#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct VerifiedExcision {
546    /// The conversation the marker is bound to.
547    pub conversation_id: String,
548    /// [`EXCISION_SCOPE_CASCADE`] or [`EXCISION_SCOPE_SOURCE_ONLY`].
549    pub scope: String,
550    /// The named journal positions.
551    pub positions: Vec<u64>,
552    /// Who requested the excision (persona id or operator identity).
553    pub requested_by: String,
554    /// Free-text audit reason.
555    pub reason: String,
556    /// The verified signer's public key (encoded).
557    pub signer_public_key: Vec<u8>,
558}
559
560impl VerifiedExcision {
561    /// Whether this marker's scope is the cascading (sound-default) one.
562    #[must_use]
563    pub fn is_cascade(&self) -> bool {
564        self.scope == EXCISION_SCOPE_CASCADE
565    }
566}
567
568/// Verify a persisted `taint_excision` payload.
569///
570/// `None` for a malformed payload, an unknown scope, or a signature that
571/// does not verify — the caller ignores the marker and taint stays (fail
572/// closed).
573#[must_use]
574pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
575    let v: Value = serde_json::from_slice(payload).ok()?;
576    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
577    let scope = v.get("scope")?.as_str()?.to_owned();
578    if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
579        return None;
580    }
581    let positions: Vec<u64> = v
582        .get("positions")?
583        .as_array()?
584        .iter()
585        .map(serde_json::Value::as_u64)
586        .collect::<Option<Vec<_>>>()?;
587    let requested_by = v.get("requested_by")?.as_str()?.to_owned();
588    let reason = v.get("reason")?.as_str()?.to_owned();
589    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
590    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
591    let canonical =
592        excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
593    if verify(&pk, &canonical, &sig) {
594        Some(VerifiedExcision {
595            conversation_id,
596            scope,
597            positions,
598            requested_by,
599            reason,
600            signer_public_key: pk,
601        })
602    } else {
603        None
604    }
605}
606
607/// JSON payload for an `approval_deferred` event (`#67` "send back").
608///
609/// A defer records that the approver bounced the call back without approving or
610/// denying it — the audit trail shows the intent, but the pending
611/// `approval_request` is NOT resolved (no `approval_response`), so the call stays
612/// open. The signature commits to the call identity (`request_id`), the
613/// `conversation_id` it was deferred in, and the free-form `reason`; `signed_by`
614/// and `signature_hex` are appended after signing and are not covered.
615///
616/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
617#[must_use]
618pub fn deferred_payload(
619    request_id: &str,
620    conversation_id: &str,
621    reason: &str,
622    signer: &ApprovalSigner,
623) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
624    let canonical = serde_json::json!({
625        "request_id": request_id,
626        "conversation_id": conversation_id,
627        "reason": reason,
628    })
629    .to_string()
630    .into_bytes();
631    let signature = signer.sign(&canonical);
632    let pk = signer.public_key_bytes();
633    let full = serde_json::json!({
634        "request_id": request_id,
635        "conversation_id": conversation_id,
636        "reason": reason,
637        "signed_by": crate::hex::lower(&pk),
638        "signature_hex": crate::hex::lower(&signature),
639    });
640    (full.to_string().into_bytes(), signature, pk)
641}
642
643/// Verify a persisted `approval_deferred` payload (`#67`).
644///
645/// Returns `Some((request_id, conversation_id, reason))` when the signature
646/// checks out against the embedded key, else `None`.
647#[must_use]
648pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
649    let v: Value = serde_json::from_slice(payload).ok()?;
650    let request_id = v.get("request_id")?.as_str()?.to_owned();
651    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
652    let reason = v.get("reason")?.as_str()?.to_owned();
653    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
654    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
655    let canonical = serde_json::json!({
656        "request_id": request_id,
657        "conversation_id": conversation_id,
658        "reason": reason,
659    })
660    .to_string()
661    .into_bytes();
662    verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
663}
664
665/// JSON payload for a dispatch-mutation event (`#67`, #539/#540).
666///
667/// A signed record that a policy rewrote a call's args (`tool_input_rewrite`),
668/// injected context (`tool_context_injection`), or redacted a result
669/// (`tool_result_redaction`).
670///
671/// The signature commits to the event `kind` (so a record can't be re-filed under
672/// another mutation kind), the call identity (`tool_call_id`, `tool_name`), the
673/// conversation, and the mutation's `before`/`after` (proposed→executed args,
674/// or empty→context, or original→redacted result). `signed_by` / `signature_hex`
675/// are appended after signing and not covered. Returns
676/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
677#[must_use]
678#[allow(clippy::too_many_arguments)] // each is a distinct signed field of the canonical contract
679pub fn mutation_payload(
680    kind: &str,
681    tool_call_id: &str,
682    tool_name: &str,
683    conversation_id: &str,
684    before: &str,
685    after: &str,
686    signer: &ApprovalSigner,
687) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
688    let canonical = serde_json::json!({
689        "kind": kind,
690        "tool_call_id": tool_call_id,
691        "tool_name": tool_name,
692        "conversation_id": conversation_id,
693        "before": before,
694        "after": after,
695    })
696    .to_string()
697    .into_bytes();
698    let signature = signer.sign(&canonical);
699    let pk = signer.public_key_bytes();
700    let full = serde_json::json!({
701        "kind": kind,
702        "tool_call_id": tool_call_id,
703        "tool_name": tool_name,
704        "conversation_id": conversation_id,
705        "before": before,
706        "after": after,
707        "signed_by": crate::hex::lower(&pk),
708        "signature_hex": crate::hex::lower(&signature),
709    });
710    (full.to_string().into_bytes(), signature, pk)
711}
712
713/// Verify a persisted dispatch-mutation payload (`#67`).
714///
715/// Returns the signed `(kind, tool_call_id, tool_name, conversation_id, before,
716/// after)` when the signature checks out against the embedded key, else `None`.
717#[must_use]
718pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
719    let v: Value = serde_json::from_slice(payload).ok()?;
720    let kind = v.get("kind")?.as_str()?.to_owned();
721    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
722    let tool_name = v.get("tool_name")?.as_str()?.to_owned();
723    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
724    let before = v.get("before")?.as_str()?.to_owned();
725    let after = v.get("after")?.as_str()?.to_owned();
726    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
727    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
728    let canonical = serde_json::json!({
729        "kind": kind,
730        "tool_call_id": tool_call_id,
731        "tool_name": tool_name,
732        "conversation_id": conversation_id,
733        "before": before,
734        "after": after,
735    })
736    .to_string()
737    .into_bytes();
738    verify(&pk, &canonical, &sig).then_some((
739        kind,
740        tool_call_id,
741        tool_name,
742        conversation_id,
743        before,
744        after,
745    ))
746}
747
748/// Reason-string prefix marking an `approval_response` as a reviewer-agent
749/// auto-approval (`#377`), as opposed to a human decision.
750///
751/// The reviewer signs the EXACT same canonical `approval_response` a human
752/// would — same [`response_payload`], same signer, same bound identity
753/// (`request_id` + `tool_name` + `args_json` + `caller` + `sandbox_mode`),
754/// `approved == true`, `approved_for_session == false` — so the wire/signature
755/// contract is byte-for-byte identical and every existing verify path accepts
756/// it unchanged. The ONLY field distinguishing an auto-approval from a human
757/// one is the signed `reason`, which carries this prefix. Because `reason` is
758/// covered by the signature (`response_canonical`), the distinction is
759/// unforgeable: a compromised forwarder can neither launder an auto-approval as
760/// human nor a human decision as auto without invalidating the signature. The
761/// event log is therefore auditable for machine-vs-human consent off this one
762/// signed field — the guardrail `#377` requires.
763pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
764
765/// Build the signed `reason` for a reviewer auto-approval at risk `tier`.
766///
767/// Carries [`AUTO_REVIEW_REASON_PREFIX`] so the audit log can tell it from a
768/// human decision; `tier` (e.g. `"low"`) records WHY the classifier deemed the
769/// call auto-eligible. The control plane passes the result as the `reason`
770/// argument to the SAME [`response_payload`] the human path uses, so no
771/// separate signing surface exists.
772#[must_use]
773pub fn auto_review_reason(tier: &str) -> String {
774    format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
775}
776
777/// Whether a signed `reason` marks its `approval_response` as a reviewer
778/// auto-approval (`#377`) rather than a human decision.
779///
780/// The audit distinguisher; it reads the signed `reason` field, so it cannot be
781/// spoofed without breaking the signature.
782#[must_use]
783pub fn is_auto_review_reason(reason: &str) -> bool {
784    reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
785}
786
787/// Signed `reason` recorded for the blanket `approve-all-dangerous` mode.
788///
789/// Set by `POLYCHROME_APPROVAL_MODE=approve-all-dangerous` — the legible single
790/// approve-all surface that replaced the legacy `POLYCHROME_APPROVE_ALL` flag.
791/// Unlike [`auto_review_reason`] this is NOT a risk-classified verdict: it marks
792/// an unconditional machine approval, so the audit log can tell a blanket
793/// test-rig approval apart from both a human decision and a reviewer
794/// auto-approval. Like every other reason it is covered by the signature, so the
795/// distinction is unforgeable.
796pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
797
798/// A decoded `approval_response` payload after signature verification.
799#[derive(Debug, Clone)]
800pub struct VerifiedResponse {
801    /// The tool-call id this response answers.
802    pub request_id: String,
803    /// The bound tool name — the approval applies only to this exact call.
804    pub tool_name: String,
805    /// The bound `args_json` — the model's PROPOSED args, the identity the
806    /// approval is bound to. [`Self::authorizes_call`] matches this byte-for-byte,
807    /// so a re-emitted call with different args cannot inherit the approval. This
808    /// is the args the approver saw, NOT necessarily the args that execute.
809    pub args_json: String,
810    /// The approver's EDIT to the proposed args — the args to actually execute,
811    /// or empty when the approver did not edit (execute `args_json` unchanged).
812    /// Signed, so the edit is unforgeable and auditable; the delta from
813    /// `args_json` is the recorded mutation. Resolve the effective execution args
814    /// with the pure `polyc_agent::resolve_approved_call`.
815    pub modified_args_json: String,
816    /// Whether the request was approved.
817    pub approved: bool,
818    /// Whether the approval is remembered for the rest of the session ("don't
819    /// ask again"); `false` for a one-shot approval.
820    pub approved_for_session: bool,
821    /// The capability shortfall this approval covered (`#595`): the stable
822    /// kebab-case capability names the gate reported missing when it paused
823    /// the call. Covered by the signature, so the effective session-grant key
824    /// is (caller, tool, covered capabilities) — a grant recorded against one
825    /// covered set never satisfies the same tool after its required set grows.
826    /// Empty for an approval of an ordinary policy/sandbox gate.
827    pub covered_capabilities: Vec<String>,
828    /// The caller identity the (session) approval is scoped to — the paused
829    /// turn's own beneficiary (RFC 8693 `sub`/subject), NOT necessarily who
830    /// clicked. Set by the trusted control plane and covered by the
831    /// signature, so a session grant cannot be re-scoped to a different user.
832    pub caller: String,
833    /// The identity that actually resolved this decision (`#1025`, RFC 8693
834    /// `act`/actor), when the edge supplied one — empty otherwise (no edge
835    /// integration yet, or a payload signed before this field existed).
836    /// Distinct from `caller`: an admin approving on someone else's behalf
837    /// signs a different `approver` than `caller`. For approval-policy
838    /// checks and the audit trail ONLY — never fed into `principal_ref` (see
839    /// `caller`'s own resume-attribution use in the control plane).
840    pub approver: String,
841    /// The sandbox/permission mode the paused turn ran under, covered by the
842    /// signature so a grant cannot be replayed under a different mode.
843    pub sandbox_mode: String,
844    /// Free-form human-supplied reason.
845    pub reason: String,
846    /// Context the approver attached to inject before the tool runs — prepended
847    /// as an `internal_only` message ahead of execution, or empty when none.
848    /// Signed, so an injected instruction is unforgeable and recorded.
849    pub injected_context: String,
850    /// The conversation the approval was granted in, covered by the signature so
851    /// a token signed for one conversation cannot be replayed into another
852    /// (`#370`, closes `#77` bug 3B).
853    pub conversation_id: String,
854    /// Per-approval unique value, covered by the signature. A consumer records it
855    /// on use so the token cannot be re-presented once spent (single-use, `#370`).
856    pub nonce: String,
857    /// The verified signer's public key (encoded).
858    pub signer_public_key: Vec<u8>,
859}
860
861impl VerifiedResponse {
862    /// Whether this verified, approved token authorizes the EXACT call
863    /// `(request_id, tool_name, args_json)` — the args-binding (`#370` item 3).
864    ///
865    /// The signed `args_json` is matched byte-for-byte, so a re-emitted same-id
866    /// call with different arguments (or a different tool) is NOT authorized: a
867    /// captured approval can never be reused to run a different action. A denial
868    /// (`approved == false`) authorizes nothing.
869    #[must_use]
870    pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
871        self.approved
872            && self.request_id == request_id
873            && self.tool_name == tool_name
874            && self.args_json == args_json
875    }
876}
877
878/// Verify a persisted `approval_response` payload.
879///
880/// Returns `Some(record)` if the signature checks out against the embedded
881/// public key (the caller is responsible for trusting that public key — a key
882/// allow-list lives alongside this in production). Returns `None` if the payload
883/// is malformed, the hex fields don't decode, or the signature doesn't verify.
884#[must_use]
885pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
886    let v: Value = serde_json::from_slice(payload).ok()?;
887    let request_id = v.get("request_id")?.as_str()?.to_owned();
888    let tool_name = v.get("tool_name")?.as_str()?.to_owned();
889    let args_json = v.get("args_json")?.as_str()?.to_owned();
890    let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
891    let approved = v.get("approved")?.as_bool()?;
892    let approved_for_session = v.get("approved_for_session")?.as_bool()?;
893    let covered_capabilities: Vec<String> = v
894        .get("covered_capabilities")?
895        .as_array()?
896        .iter()
897        .map(|c| c.as_str().map(str::to_owned))
898        .collect::<Option<Vec<_>>>()?;
899    let caller = v.get("caller")?.as_str()?.to_owned();
900    // #1025: absent on every payload signed before this field existed (and
901    // on any signed today with an empty approver — omitted, not `""`, at
902    // sign time) — defaults to empty, NOT a decode failure.
903    let approver_id = v
904        .get("approver")
905        .and_then(|x| x.as_str())
906        .unwrap_or_default()
907        .to_owned();
908    let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
909    let reason = v.get("reason")?.as_str()?.to_owned();
910    let injected_context = v.get("injected_context")?.as_str()?.to_owned();
911    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
912    let nonce = v.get("nonce")?.as_str()?.to_owned();
913    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
914    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
915
916    let canonical = response_canonical(
917        &request_id,
918        &tool_name,
919        &args_json,
920        &modified_args_json,
921        approved,
922        approved_for_session,
923        &covered_capabilities,
924        &caller,
925        &approver_id,
926        &sandbox_mode,
927        &reason,
928        &injected_context,
929        &conversation_id,
930        &nonce,
931    );
932    if verify(&pk, &canonical, &sig) {
933        Some(VerifiedResponse {
934            request_id,
935            tool_name,
936            args_json,
937            modified_args_json,
938            approved,
939            approved_for_session,
940            covered_capabilities,
941            caller,
942            approver: approver_id,
943            sandbox_mode,
944            reason,
945            injected_context,
946            conversation_id,
947            nonce,
948            signer_public_key: pk,
949        })
950    } else {
951        None
952    }
953}
954
955/// Verify a persisted `approval_response` payload against a **trusted-signer
956/// allow-list** (`#845`).
957///
958/// Like [`verify_signed_response`] but additionally rejects any payload whose
959/// embedded `signed_by` key is not one of `trusted_signers` — the deployment's
960/// pinned approval public key(s), typically the running
961/// [`ApprovalSigner::public_key_bytes`], since every `approval_response` this
962/// control plane persists is self-signed with it. Without this gate an attacker
963/// could sign a well-formed decision with their own key, embed it, and have it
964/// honored as an approval. Mirrors [`verify_signed_receipt`]'s allow-list gate.
965/// Returns `None` (fail closed) on a malformed payload, an untrusted signer, or
966/// a bad signature.
967#[must_use]
968pub fn verify_signed_response_pinned(
969    payload: &[u8],
970    trusted_signers: &[Vec<u8>],
971) -> Option<VerifiedResponse> {
972    let verified = verify_signed_response(payload)?;
973    // Reject an untrusted signer even though its signature is self-consistent:
974    // anyone can mint a keypair, embed its public key, and self-sign.
975    if !signer_is_trusted(&verified.signer_public_key, trusted_signers) {
976        return None;
977    }
978    Some(verified)
979}
980
981/// Verify a persisted `approval_response` as a SINGLE-USE, conversation-bound
982/// capability token (`#370`), gated on a **trusted-signer allow-list** (`#845`).
983///
984/// Returns the verified response only when ALL hold:
985/// * the embedded `signed_by` key is a member of `trusted_signers` — the
986///   deployment's pinned approval public key(s) (see
987///   [`verify_signed_response_pinned`]); an internally-consistent signature over
988///   an untrusted key authorizes nothing;
989/// * the signature verifies against that key (provenance);
990/// * the signed `conversation_id` equals `conversation_id` — a token signed for
991///   one conversation is rejected when presented for another (closes `#77`
992///   bug 3B);
993/// * the signed `nonce` is non-empty AND not already in `consumed` — a token
994///   that has been spent (its nonce recorded on a prior use) is rejected.
995///
996/// The caller binds the token to a specific call by matching the returned
997/// [`VerifiedResponse::authorizes_call`], and MUST record the returned
998/// [`VerifiedResponse::nonce`] into its `consumed` set before honoring it, so a
999/// second presentation of the same token is rejected. An empty nonce is treated
1000/// as malformed and fails closed (every minted token carries one).
1001#[must_use]
1002pub fn verify_capability<S: std::hash::BuildHasher>(
1003    payload: &[u8],
1004    conversation_id: &str,
1005    consumed: &HashSet<String, S>,
1006    trusted_signers: &[Vec<u8>],
1007) -> Option<VerifiedResponse> {
1008    let verified = verify_signed_response_pinned(payload, trusted_signers)?;
1009    if verified.conversation_id != conversation_id {
1010        return None;
1011    }
1012    if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
1013        return None;
1014    }
1015    Some(verified)
1016}
1017
1018/// Verify a wire-form `approval_response`, binding the approval to its full
1019/// signed identity `(request_id, tool_name, args_json, approved,
1020/// approved_for_session, caller, reason)`.
1021///
1022/// Used by the harness when it receives `HarnessMessage.approval_responses` over
1023/// the wire and must confirm provenance AND identity before executing the paused
1024/// tool or honoring a "don't ask again" grant. The `caller` is covered by the
1025/// signature, so a compromised control plane cannot re-scope a remembered
1026/// approval onto a different user. Returns `true` only if `signer_pk_hex +
1027/// signature_hex` validates against the canonical.
1028#[must_use]
1029#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
1030pub fn verify_wire_response(
1031    request_id: &str,
1032    tool_name: &str,
1033    args_json: &str,
1034    modified_args_json: &str,
1035    approved: bool,
1036    approved_for_session: bool,
1037    covered_capabilities: &[String],
1038    caller: &str,
1039    approver_id: &str,
1040    sandbox_mode: &str,
1041    reason: &str,
1042    injected_context: &str,
1043    conversation_id: &str,
1044    nonce: &str,
1045    signer_pk_hex: &str,
1046    signature_hex: &str,
1047) -> bool {
1048    let Some(pk) = crate::hex::decode(signer_pk_hex) else {
1049        return false;
1050    };
1051    let Some(sig) = crate::hex::decode(signature_hex) else {
1052        return false;
1053    };
1054    let canonical = response_canonical(
1055        request_id,
1056        tool_name,
1057        args_json,
1058        modified_args_json,
1059        approved,
1060        approved_for_session,
1061        covered_capabilities,
1062        caller,
1063        approver_id,
1064        sandbox_mode,
1065        reason,
1066        injected_context,
1067        conversation_id,
1068        nonce,
1069    );
1070    verify(&pk, &canonical, &sig)
1071}
1072
1073/// Whether an approval response is a session ("don't ask again") grant for
1074/// `current_caller`.
1075///
1076/// True when it is approved, flagged for the session, and bound to a non-empty
1077/// `caller` equal to the current turn's caller.
1078///
1079/// This is the per-USER isolation invariant — user A's remembered approval must
1080/// never auto-approve user B in a shared conversation. It lives here, in one
1081/// place, so the harness (which re-verifies signed wire responses) and the
1082/// control plane (the in-process path) cannot drift on *who* a remembered
1083/// approval applies to. Callers still gate the TOOL on its idempotency
1084/// separately ([`crate`] does not know tool policy).
1085#[must_use]
1086pub fn is_session_grant_for(
1087    approved: bool,
1088    approved_for_session: bool,
1089    caller: &str,
1090    current_caller: &str,
1091) -> bool {
1092    approved && approved_for_session && !caller.is_empty() && caller == current_caller
1093}
1094
1095/// Extract `(request_id, approved)` from an `approval_response` payload.
1096///
1097/// Used by replay to find which pending requests have been answered. Skips
1098/// signature verification on the assumption the caller has already accepted
1099/// the entry — pair with [`verify_signed_response`] when trust matters.
1100#[must_use]
1101pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
1102    let v: Value = serde_json::from_slice(payload).ok()?;
1103    let request_id = v.get("request_id")?.as_str()?.to_owned();
1104    let approved = v.get("approved")?.as_bool()?;
1105    Some((request_id, approved))
1106}
1107
1108/// Every decoded field of an `approval_response` payload (unverified).
1109#[derive(Debug, Clone)]
1110pub struct DecodedResponse {
1111    /// Tool-call id this response answers.
1112    pub request_id: String,
1113    /// Bound tool name.
1114    pub tool_name: String,
1115    /// Bound `args_json` — the model's proposed args (identity binding).
1116    pub args_json: String,
1117    /// The approver's edit to the proposed args (empty = unedited).
1118    pub modified_args_json: String,
1119    /// Approve / deny decision.
1120    pub approved: bool,
1121    /// Whether the approval is remembered for the session ("don't ask again").
1122    pub approved_for_session: bool,
1123    /// The capability shortfall this approval covered (`#595`).
1124    pub covered_capabilities: Vec<String>,
1125    /// The caller identity the (session) approval is scoped to — the paused
1126    /// turn's own beneficiary, NOT necessarily who clicked. See `approver`.
1127    pub caller: String,
1128    /// The identity that actually resolved this decision (`#1025`), when the
1129    /// edge supplied one — empty otherwise (no edge integration yet, or a
1130    /// payload signed before this field existed). Distinct from `caller`.
1131    pub approver: String,
1132    /// The sandbox/permission mode the grant was made under.
1133    pub sandbox_mode: String,
1134    /// Human-supplied reason.
1135    pub reason: String,
1136    /// Context the approver attached to inject before execution (empty = none).
1137    pub injected_context: String,
1138    /// The conversation the approval was granted in (`#370` binding).
1139    pub conversation_id: String,
1140    /// Per-approval single-use nonce (`#370` binding).
1141    pub nonce: String,
1142    /// Signer public key, hex.
1143    pub signer_pk_hex: String,
1144    /// Signature, hex.
1145    pub signature_hex: String,
1146}
1147
1148/// Decode every field of an `approval_response` payload without verifying.
1149///
1150/// Used by the control plane to forward signed responses onto the harness wire;
1151/// the harness re-verifies on receipt.
1152#[must_use]
1153pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
1154    let v: Value = serde_json::from_slice(payload).ok()?;
1155    Some(DecodedResponse {
1156        request_id: v.get("request_id")?.as_str()?.to_owned(),
1157        tool_name: v.get("tool_name")?.as_str()?.to_owned(),
1158        args_json: v.get("args_json")?.as_str()?.to_owned(),
1159        modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
1160        approved: v.get("approved")?.as_bool()?,
1161        approved_for_session: v.get("approved_for_session")?.as_bool()?,
1162        covered_capabilities: v
1163            .get("covered_capabilities")
1164            .and_then(Value::as_array)
1165            .map(|a| {
1166                a.iter()
1167                    .filter_map(|c| c.as_str().map(str::to_owned))
1168                    .collect()
1169            })
1170            .unwrap_or_default(),
1171        caller: v.get("caller")?.as_str()?.to_owned(),
1172        // #1025: absent on every payload signed before this field existed —
1173        // defaults to empty, NOT a decode failure (see `response_canonical`).
1174        approver: v
1175            .get("approver")
1176            .and_then(|x| x.as_str())
1177            .unwrap_or_default()
1178            .to_owned(),
1179        sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
1180        reason: v.get("reason")?.as_str()?.to_owned(),
1181        injected_context: v.get("injected_context")?.as_str()?.to_owned(),
1182        conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
1183        nonce: v.get("nonce")?.as_str()?.to_owned(),
1184        signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
1185        signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
1186    })
1187}
1188
1189/// Current signed `payment_receipt` schema version.
1190///
1191/// **v2** makes a settled receipt self-describing: alongside the original
1192/// settlement facts it covers the event `kind` (direction — without it an
1193/// inbound payload could be re-filed under the outbound kind, or vice versa,
1194/// since the stored `Event.kind` is not itself signed), the binding fields
1195/// (`tool_call_id`, `approval_pos`, `approved_args_hash`), and an opaque
1196/// `subject` — so an auditor can bind the receipt to the exact approved tool
1197/// call it answered without walking the log to the separate
1198/// `outbound_payment_attempt` event. **v1** (legacy, no `version` field) signed
1199/// only the six settlement facts; it still *verifies* for forensics but carries
1200/// no kind or binding tuple.
1201pub const RECEIPT_VERSION: u64 = 2;
1202
1203/// The receipt body fields [`receipt_payload`] signs, named at the call site.
1204///
1205/// Replaces positional `&str` arguments: with the positional form, two
1206/// same-typed fields (e.g. `recipient` and `method`) could be swapped at a call
1207/// site and still compile, silently signing a corrupt receipt. Naming the
1208/// fields here makes such a swap a compile error.
1209///
1210/// The field set, names, and the order they are serialized in
1211/// [`receipt_payload`] are the signed-payload contract: they must NOT change
1212/// without a version bump, or previously-persisted receipts stop verifying.
1213///
1214/// `kind` and the trailing four fields are **v2** additions: the event kind
1215/// (direction) plus the binding tuple and an opaque `subject`. Inbound (the
1216/// server was *paid*) receipts have no approved tool call, so they pass empty
1217/// strings for the binding tuple and subject; outbound (the control plane
1218/// *paid* a 402 service) receipts populate them. Both directions sign their
1219/// `kind`.
1220#[derive(Debug, Clone, Copy)]
1221pub struct ReceiptPayload<'a> {
1222    /// The event kind the payload is stored under (`payment_receipt` for
1223    /// inbound, `outbound_payment_receipt` for outbound). Signed so a payload
1224    /// cannot be re-filed under the other direction's kind (v2; empty for
1225    /// legacy v1).
1226    pub kind: &'a str,
1227    /// Chain/transaction reference (e.g. tx id) the receipt settles.
1228    pub reference: &'a str,
1229    /// Decimal amount as a string (avoids float drift).
1230    pub amount: &'a str,
1231    /// Currency / asset symbol.
1232    pub currency: &'a str,
1233    /// Recipient address.
1234    pub recipient: &'a str,
1235    /// Settlement method (e.g. `tempo`).
1236    pub method: &'a str,
1237    /// RFC3339 settlement timestamp.
1238    pub timestamp: &'a str,
1239    /// The `paid_fetch` tool-call id this payment answered (v2; empty for
1240    /// inbound and legacy v1).
1241    pub tool_call_id: &'a str,
1242    /// Decimal string of the `approval_request` log position the payment
1243    /// answered (v2; empty for inbound and legacy v1).
1244    pub approval_pos: &'a str,
1245    /// sha256 hex of the approved `args_json` — the same idempotency-key
1246    /// component the `outbound_payment_attempt` marker carries, so a verifier
1247    /// can cross-check the receipt against the attempt (v2; empty otherwise).
1248    pub approved_args_hash: &'a str,
1249    /// Opaque principal the spend is attributed to. Currently the conversation
1250    /// id; the structured agent/tenant identity is supplied later by the
1251    /// declarative-catalog identity model. Treat as opaque (v2; empty otherwise).
1252    pub subject: &'a str,
1253}
1254
1255impl ReceiptPayload<'_> {
1256    /// Build the canonical (unsigned) **v2** JSON the signature commits to.
1257    ///
1258    /// This is the SINGLE source for the signed v2 receipt field set and order:
1259    /// both the signing path ([`receipt_payload`]) and the verifying path
1260    /// ([`verify_signed_receipt`]) route their canonical bytes through here,
1261    /// so adding, renaming, or reordering a covered field is a one-line edit
1262    /// and the two paths cannot drift. The key set/order is the on-wire
1263    /// signed contract and must NOT change without a version bump.
1264    #[must_use]
1265    fn canonical_json(&self) -> Value {
1266        serde_json::json!({
1267            "version": RECEIPT_VERSION,
1268            "kind": self.kind,
1269            "reference": self.reference,
1270            "amount": self.amount,
1271            "currency": self.currency,
1272            "recipient": self.recipient,
1273            "method": self.method,
1274            "timestamp": self.timestamp,
1275            "tool_call_id": self.tool_call_id,
1276            "approval_pos": self.approval_pos,
1277            "approved_args_hash": self.approved_args_hash,
1278            "subject": self.subject,
1279        })
1280    }
1281
1282    /// Legacy **v1** canonical (the original six settlement fields, no
1283    /// `version`). Retained only so receipts persisted before the v2 binding
1284    /// still *verify* for forensics; new receipts always sign v2.
1285    #[must_use]
1286    fn canonical_json_v1(&self) -> Value {
1287        serde_json::json!({
1288            "reference": self.reference,
1289            "amount": self.amount,
1290            "currency": self.currency,
1291            "recipient": self.recipient,
1292            "method": self.method,
1293            "timestamp": self.timestamp,
1294        })
1295    }
1296}
1297
1298/// JSON payload for a `payment_receipt` event.
1299///
1300/// Mirrors [`response_payload`] exactly: the signature commits to the
1301/// canonical (unsigned) JSON form of the receipt body
1302/// (`reference`, `amount`, `currency`, `recipient`, `method`, `timestamp`).
1303/// `signed_by` and `signature_hex` are populated *after* the signer runs and
1304/// are NOT covered by the signature itself. Tampering with any body field
1305/// invalidates the signature.
1306///
1307/// The fields arrive as a single named [`ReceiptPayload`] (rather than six
1308/// positional strings) so a call site cannot silently swap two same-typed
1309/// fields; the serialized key set/order is unchanged and remains the signed
1310/// contract.
1311///
1312/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
1313#[must_use]
1314pub fn receipt_payload(
1315    fields: &ReceiptPayload<'_>,
1316    signer: &ApprovalSigner,
1317) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1318    let mut full = fields.canonical_json();
1319    let canonical_bytes = full.to_string().into_bytes();
1320    let signature = signer.sign(&canonical_bytes);
1321    let pk = signer.public_key_bytes();
1322    // The full payload is the canonical body plus the two signature fields,
1323    // which are NOT covered by the signature. Append them to the single-source
1324    // canonical object so the body field set still lives only in
1325    // `ReceiptPayload::canonical_json`.
1326    if let Value::Object(map) = &mut full {
1327        map.insert(
1328            "signed_by".to_owned(),
1329            Value::String(crate::hex::lower(&pk)),
1330        );
1331        map.insert(
1332            "signature_hex".to_owned(),
1333            Value::String(crate::hex::lower(&signature)),
1334        );
1335    }
1336    (full.to_string().into_bytes(), signature, pk)
1337}
1338
1339/// A decoded `payment_receipt` payload after signature verification.
1340#[derive(Debug, Clone)]
1341pub struct VerifiedReceipt {
1342    /// Chain/transaction reference (e.g. tx id) the receipt settles.
1343    pub reference: String,
1344    /// Decimal amount, as a string (avoids float drift).
1345    pub amount: String,
1346    /// Currency / asset symbol.
1347    pub currency: String,
1348    /// Recipient address.
1349    pub recipient: String,
1350    /// Settlement method (e.g. `tempo`).
1351    pub method: String,
1352    /// RFC3339 settlement timestamp.
1353    pub timestamp: String,
1354    /// Schema version (`1` = legacy settlement-only, `2` = kind + binding
1355    /// tuple present).
1356    pub version: u64,
1357    /// The signed event kind (`payment_receipt` or `outbound_payment_receipt`).
1358    /// Callers should check it matches the kind the event was stored under —
1359    /// the stored kind itself is not signed (v2; empty for v1).
1360    pub kind: String,
1361    /// The `paid_fetch` tool-call id this payment answered (v2; empty for v1).
1362    pub tool_call_id: String,
1363    /// Decimal string of the `approval_request` log position (v2; empty for v1).
1364    pub approval_pos: String,
1365    /// sha256 hex of the approved `args_json` (v2; empty for v1).
1366    pub approved_args_hash: String,
1367    /// Opaque principal the spend is attributed to (v2; empty for v1).
1368    pub subject: String,
1369    /// The verified signer's public key (encoded).
1370    pub signer_public_key: Vec<u8>,
1371}
1372
1373/// Verify a persisted `payment_receipt`/`outbound_payment_receipt` payload
1374/// against a **trusted-signer allow-list**.
1375///
1376/// Returns `Some(record)` only if the signature checks out against the
1377/// embedded public key AND that key is a member of `trusted_signers`. An
1378/// internally-consistent signature over an *unknown* key proves the payload
1379/// was not tampered with after signing — it proves nothing about whether the
1380/// signer should be trusted; anyone can mint a fresh keypair, embed its own
1381/// public key, and sign an arbitrary settlement, so a caller MUST supply the
1382/// deployment's own set of trusted signers here (typically the deployment's
1383/// [`ApprovalSigner::public_key_bytes`], since receipts are self-signed by
1384/// this same control plane) rather than treating "verifies" as "trustworthy".
1385///
1386/// Returns `None` if the payload is malformed, the hex fields don't decode,
1387/// the embedded key is not in `trusted_signers`, or the signature doesn't
1388/// verify.
1389#[must_use]
1390pub fn verify_signed_receipt(
1391    payload: &[u8],
1392    trusted_signers: &[Vec<u8>],
1393) -> Option<VerifiedReceipt> {
1394    let v: Value = serde_json::from_slice(payload).ok()?;
1395    let reference = v.get("reference")?.as_str()?.to_owned();
1396    let amount = v.get("amount")?.as_str()?.to_owned();
1397    let currency = v.get("currency")?.as_str()?.to_owned();
1398    let recipient = v.get("recipient")?.as_str()?.to_owned();
1399    let method = v.get("method")?.as_str()?.to_owned();
1400    let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1401    let signed_by_hex = v.get("signed_by")?.as_str()?;
1402    let signature_hex = v.get("signature_hex")?.as_str()?;
1403    let pk = crate::hex::decode(signed_by_hex)?;
1404    let sig = crate::hex::decode(signature_hex)?;
1405    // Reject an unknown signer before doing any further work (including the
1406    // signature check below): a key that is not on the allow-list is not
1407    // trusted no matter how internally consistent its signature is — anyone
1408    // can mint a keypair and self-sign an arbitrary receipt.
1409    if !signer_is_trusted(&pk, trusted_signers) {
1410        return None;
1411    }
1412    // A legacy receipt carries no `version` key; treat it as v1. A present
1413    // `version` must equal the exact current version — any other value
1414    // (including an explicit `1`, a future `3`, or a non-integer) is refused
1415    // outright rather than verified against a guessed canonical. The v1
1416    // canonical does not cover the `version` key, so dispatching an unknown
1417    // claimed version to v1 would let a valid v1 signature verify while the
1418    // output echoed an unsigned, writer-chosen version — fail closed instead.
1419    let version = match v.get("version") {
1420        None => 1,
1421        Some(n) if n.as_u64() == Some(RECEIPT_VERSION) => RECEIPT_VERSION,
1422        Some(_) => return None,
1423    };
1424    let (kind, tool_call_id, approval_pos, approved_args_hash, subject) =
1425        if version == RECEIPT_VERSION {
1426            (
1427                v.get("kind")?.as_str()?.to_owned(),
1428                v.get("tool_call_id")?.as_str()?.to_owned(),
1429                v.get("approval_pos")?.as_str()?.to_owned(),
1430                v.get("approved_args_hash")?.as_str()?.to_owned(),
1431                v.get("subject")?.as_str()?.to_owned(),
1432            )
1433        } else {
1434            (
1435                String::new(),
1436                String::new(),
1437                String::new(),
1438                String::new(),
1439                String::new(),
1440            )
1441        };
1442    // Rebuild the canonical bytes via the SAME single source the signer used,
1443    // so the verify path can never check a different field set/order.
1444    let fields = ReceiptPayload {
1445        kind: &kind,
1446        reference: &reference,
1447        amount: &amount,
1448        currency: &currency,
1449        recipient: &recipient,
1450        method: &method,
1451        timestamp: &timestamp,
1452        tool_call_id: &tool_call_id,
1453        approval_pos: &approval_pos,
1454        approved_args_hash: &approved_args_hash,
1455        subject: &subject,
1456    };
1457    let canonical_bytes = if version == RECEIPT_VERSION {
1458        fields.canonical_json()
1459    } else {
1460        fields.canonical_json_v1()
1461    }
1462    .to_string()
1463    .into_bytes();
1464    if verify(&pk, &canonical_bytes, &sig) {
1465        Some(VerifiedReceipt {
1466            reference,
1467            amount,
1468            currency,
1469            recipient,
1470            method,
1471            timestamp,
1472            version,
1473            kind,
1474            tool_call_id,
1475            approval_pos,
1476            approved_args_hash,
1477            subject,
1478            signer_public_key: pk,
1479        })
1480    } else {
1481        None
1482    }
1483}
1484
1485/// TTL for a minted `resolve_token` (`#787`).
1486///
1487/// Generous enough that a human has time to see and act on the approval card
1488/// (which can sit in a Slack/Telegram thread for hours), short enough that a
1489/// token captured off a stale card cannot resolve the request indefinitely.
1490/// Independent of the underlying approval's own lifetime — a request that
1491/// outlives the TTL simply needs the control plane to re-mint (a fresh
1492/// `ListPending` call re-renders the card with a fresh token).
1493pub const RESOLVE_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
1494
1495/// The canonical (signature-covered) form of a `resolve_token` (`#787`).
1496///
1497/// Binds the token to the exact `(request_id, conversation_id)` pair it was
1498/// minted for and the time it was minted, so it cannot be replayed against a
1499/// different request, a different conversation, or presented once its TTL has
1500/// elapsed.
1501fn resolve_token_canonical(request_id: &str, conversation_id: &str, minted_at_ms: u64) -> Vec<u8> {
1502    serde_json::json!({
1503        "request_id": request_id,
1504        "conversation_id": conversation_id,
1505        "minted_at_ms": minted_at_ms,
1506    })
1507    .to_string()
1508    .into_bytes()
1509}
1510
1511/// Mint a short-lived, signed `resolve_token` (`#787`) scoped to one
1512/// `(request_id, conversation_id)` pair.
1513///
1514/// This is the capability `ApprovalService.Respond` requires alongside the
1515/// plain `request_id`/`conversation_id` it already checks: minted by the
1516/// control plane at the moment it hands a pending approval to an edge for
1517/// rendering (`AgentEnd.pending_approvals`, `ListPendingReply.pending`), it is
1518/// opaque to — and unforgeable by — anything downstream of that mint point,
1519/// including a compromised harness pod (the harness never holds the signing
1520/// key and never sees the minted token; it only proposes the tool call the
1521/// token later authorizes resolving). `minted_at_ms` is the caller's wall
1522/// clock at mint time; pass a real timestamp in production, an injected one in
1523/// tests.
1524///
1525/// Returns the token as a lowercase-hex opaque string, safe to carry on any
1526/// wire surface (a button value, a proto field) alongside `request_id`.
1527#[must_use]
1528pub fn mint_resolve_token(
1529    request_id: &str,
1530    conversation_id: &str,
1531    minted_at_ms: u64,
1532    signer: &ApprovalSigner,
1533) -> String {
1534    let canonical = resolve_token_canonical(request_id, conversation_id, minted_at_ms);
1535    let signature = signer.sign(&canonical);
1536    let full = serde_json::json!({
1537        "request_id": request_id,
1538        "conversation_id": conversation_id,
1539        "minted_at_ms": minted_at_ms,
1540        "signature_hex": crate::hex::lower(&signature),
1541    });
1542    crate::hex::lower(full.to_string().as_bytes())
1543}
1544
1545/// Verify a `resolve_token` minted by [`mint_resolve_token`] against the
1546/// `(request_id, conversation_id)` a `Respond` call presents it for.
1547///
1548/// Returns `true` only when ALL hold: the token decodes, its embedded
1549/// signature verifies against `signer`'s public key, its bound `request_id`
1550/// and `conversation_id` equal the ones supplied, and `now_ms - minted_at_ms`
1551/// is within [`RESOLVE_TOKEN_TTL_MS`] (a token minted in the future, by clock
1552/// skew beyond the TTL, also fails — fail closed rather than trust an
1553/// out-of-bounds clock). Fails closed on any malformed field.
1554#[must_use]
1555pub fn verify_resolve_token(
1556    token: &str,
1557    request_id: &str,
1558    conversation_id: &str,
1559    now_ms: u64,
1560    signer: &ApprovalSigner,
1561) -> bool {
1562    let Some(bytes) = crate::hex::decode(token) else {
1563        return false;
1564    };
1565    let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
1566        return false;
1567    };
1568    let (
1569        Some(bound_request_id),
1570        Some(bound_conversation_id),
1571        Some(minted_at_ms),
1572        Some(signature_hex),
1573    ) = (
1574        v.get("request_id").and_then(Value::as_str),
1575        v.get("conversation_id").and_then(Value::as_str),
1576        v.get("minted_at_ms").and_then(Value::as_u64),
1577        v.get("signature_hex").and_then(Value::as_str),
1578    )
1579    else {
1580        return false;
1581    };
1582    if bound_request_id != request_id || bound_conversation_id != conversation_id {
1583        return false;
1584    }
1585    let elapsed = now_ms.abs_diff(minted_at_ms);
1586    if elapsed > RESOLVE_TOKEN_TTL_MS {
1587        return false;
1588    }
1589    let Some(sig) = crate::hex::decode(signature_hex) else {
1590        return false;
1591    };
1592    let canonical = resolve_token_canonical(bound_request_id, bound_conversation_id, minted_at_ms);
1593    verify(&signer.public_key_bytes(), &canonical, &sig)
1594}
1595
1596/// The canonical (signature-covered) form of an `admin_model_change` audit
1597/// record (`#787`).
1598///
1599/// Covers who made the change (the authenticated bearer principal), what the
1600/// selection moved from and to, and when — so the durable record cannot be
1601/// forged or re-attributed to a different operator without invalidating the
1602/// signature. `signed_by`/`signature_hex` are appended after signing and are
1603/// not covered.
1604fn admin_model_change_canonical(
1605    principal: &str,
1606    previous_provider: &str,
1607    previous_model: &str,
1608    new_provider: &str,
1609    new_model: &str,
1610    changed_at_ms: u64,
1611) -> Vec<u8> {
1612    serde_json::json!({
1613        "principal": principal,
1614        "previous_provider": previous_provider,
1615        "previous_model": previous_model,
1616        "new_provider": new_provider,
1617        "new_model": new_model,
1618        "changed_at_ms": changed_at_ms,
1619    })
1620    .to_string()
1621    .into_bytes()
1622}
1623
1624/// JSON payload for a signed `admin_model_change` audit event (`#787`).
1625///
1626/// The durable, tamper-evident record that the live `provider/model`
1627/// selection changed, written on every successful `POST /admin/model` — the
1628/// same posture `#590`/`#594`/`#623` established for other admin-signed
1629/// actions. Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
1630#[must_use]
1631#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
1632pub fn admin_model_change_payload(
1633    principal: &str,
1634    previous_provider: &str,
1635    previous_model: &str,
1636    new_provider: &str,
1637    new_model: &str,
1638    changed_at_ms: u64,
1639    signer: &ApprovalSigner,
1640) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1641    let canonical = admin_model_change_canonical(
1642        principal,
1643        previous_provider,
1644        previous_model,
1645        new_provider,
1646        new_model,
1647        changed_at_ms,
1648    );
1649    let signature = signer.sign(&canonical);
1650    let pk = signer.public_key_bytes();
1651    let full = serde_json::json!({
1652        "principal": principal,
1653        "previous_provider": previous_provider,
1654        "previous_model": previous_model,
1655        "new_provider": new_provider,
1656        "new_model": new_model,
1657        "changed_at_ms": changed_at_ms,
1658        "signed_by": crate::hex::lower(&pk),
1659        "signature_hex": crate::hex::lower(&signature),
1660    });
1661    (full.to_string().into_bytes(), signature, pk)
1662}
1663
1664/// A verified `admin_model_change` audit record.
1665#[derive(Debug, Clone, PartialEq, Eq)]
1666pub struct VerifiedAdminModelChange {
1667    /// The authenticated bearer principal that made the change.
1668    pub principal: String,
1669    /// Provider before the change (may be empty — deferred to harness default).
1670    pub previous_provider: String,
1671    /// Model before the change.
1672    pub previous_model: String,
1673    /// Provider after the change.
1674    pub new_provider: String,
1675    /// Model after the change.
1676    pub new_model: String,
1677    /// Unix ms the change was applied.
1678    pub changed_at_ms: u64,
1679    /// The verified signer's public key (encoded).
1680    pub signer_public_key: Vec<u8>,
1681}
1682
1683/// Verify a persisted `admin_model_change` payload.
1684///
1685/// `None` for a malformed payload or a signature that does not verify — the
1686/// caller treats the record as untrusted (fail closed).
1687#[must_use]
1688pub fn verify_admin_model_change(payload: &[u8]) -> Option<VerifiedAdminModelChange> {
1689    let v: Value = serde_json::from_slice(payload).ok()?;
1690    let principal = v.get("principal")?.as_str()?.to_owned();
1691    let previous_provider = v.get("previous_provider")?.as_str()?.to_owned();
1692    let previous_model = v.get("previous_model")?.as_str()?.to_owned();
1693    let new_provider = v.get("new_provider")?.as_str()?.to_owned();
1694    let new_model = v.get("new_model")?.as_str()?.to_owned();
1695    let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
1696    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
1697    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
1698    let canonical = admin_model_change_canonical(
1699        &principal,
1700        &previous_provider,
1701        &previous_model,
1702        &new_provider,
1703        &new_model,
1704        changed_at_ms,
1705    );
1706    if verify(&pk, &canonical, &sig) {
1707        Some(VerifiedAdminModelChange {
1708            principal,
1709            previous_provider,
1710            previous_model,
1711            new_provider,
1712            new_model,
1713            changed_at_ms,
1714            signer_public_key: pk,
1715        })
1716    } else {
1717        None
1718    }
1719}
1720
1721/// Extract `request_id` from an `approval_request` payload.
1722#[must_use]
1723pub fn decode_request_id(payload: &[u8]) -> Option<String> {
1724    let v: Value = serde_json::from_slice(payload).ok()?;
1725    Some(v.get("request_id")?.as_str()?.to_owned())
1726}
1727
1728/// Extract `(request_id, tool_name, args_json)` from an `approval_request`
1729/// payload — the fields a v2 `approval_response` must sign to bind the approval
1730/// to the request identity.
1731#[must_use]
1732pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
1733    let v: Value = serde_json::from_slice(payload).ok()?;
1734    Some((
1735        v.get("request_id")?.as_str()?.to_owned(),
1736        v.get("tool_name")?.as_str()?.to_owned(),
1737        v.get("args_json")?.as_str()?.to_owned(),
1738    ))
1739}
1740
1741/// Extract the `sandbox_mode` an `approval_request` was emitted under.
1742///
1743/// The mode the harness was running when it paused the call. Separate from
1744/// [`decode_request_fields`] so its many callers keep their tuple shape; the
1745/// control plane signs this into the response so a remembered approval is
1746/// bound to the mode it was granted under. Empty/absent → `""`.
1747#[must_use]
1748pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
1749    serde_json::from_slice::<Value>(payload)
1750        .ok()
1751        .and_then(|v| {
1752            v.get("sandbox_mode")
1753                .and_then(Value::as_str)
1754                .map(str::to_owned)
1755        })
1756        .unwrap_or_default()
1757}
1758
1759/// Extract the override `reason` an `approval_request` carried.
1760///
1761/// Non-empty only for the lethal-trifecta / Rule-of-Two containment override;
1762/// empty/absent → `""` (an ordinary gated call, or a record written before the
1763/// field existed). Used by the edge to render the gate's explanation and by
1764/// forensics to show why a trifecta-gated call was paused.
1765#[must_use]
1766pub fn decode_request_reason(payload: &[u8]) -> String {
1767    serde_json::from_slice::<Value>(payload)
1768        .ok()
1769        .and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
1770        .unwrap_or_default()
1771}
1772
1773/// Extract the `missing_capabilities` recorded on an `approval_request`
1774/// payload (`#595`) — the capability shortfall the gate computed when it
1775/// paused the call.
1776///
1777/// Read back at respond time and signed into the response as its
1778/// `covered_capabilities`. Absent or malformed decodes to empty: the grant
1779/// then covers nothing beyond the ordinary gate, the narrow direction.
1780#[must_use]
1781pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
1782    serde_json::from_slice::<Value>(payload)
1783        .ok()
1784        .and_then(|v| {
1785            v.get("missing_capabilities")
1786                .and_then(Value::as_array)
1787                .map(|a| {
1788                    a.iter()
1789                        .filter_map(|c| c.as_str().map(str::to_owned))
1790                        .collect()
1791                })
1792        })
1793        .unwrap_or_default()
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1799
1800    use super::*;
1801
1802    // #784: an `ApprovalSigner` built from real key material (the shape a
1803    // secret-store load produces) signs a genuine, verifiable
1804    // `approval_response`, and a signature minted under the well-known
1805    // deterministic `from_seed(1)` key does NOT verify against it — proving
1806    // the loaded key is a distinct, non-derivable key, not the forgeable
1807    // default.
1808    #[test]
1809    fn loaded_key_signature_verifies_and_from_seed_signature_does_not() {
1810        // Stand-in for key bytes read back from a secret store (any 32 bytes
1811        // are a valid ed25519 private key — no seed-derivation involved).
1812        let key_bytes = [42u8; 32];
1813        let loaded = ApprovalSigner::from_key_bytes(&key_bytes).expect("valid key material");
1814        let forged = ApprovalSigner::from_seed(1);
1815        assert_ne!(
1816            loaded.public_key_bytes(),
1817            forged.public_key_bytes(),
1818            "a loaded key must not collide with the public, deterministic seed-1 key"
1819        );
1820
1821        let (payload, _sig, _pk) = response_payload(
1822            "req-1",
1823            "web_fetch",
1824            r#"{"url":"https://a"}"#,
1825            "",
1826            true,
1827            false,
1828            &[],
1829            "slack:T1:U9",
1830            "",
1831            "workspace-write",
1832            "",
1833            "",
1834            "conv-1",
1835            "nonce-1",
1836            &loaded,
1837        );
1838        let verified = verify_signed_response(&payload).expect("verifies under the loaded key");
1839        assert_eq!(verified.signer_public_key, loaded.public_key_bytes());
1840
1841        // The exact same payload, signed under `from_seed(1)` and stamped
1842        // with the LOADED key's public key (impersonation attempt), fails —
1843        // the seed-1 signature does not verify against the loaded key.
1844        let (forged_payload, _sig, _pk) = response_payload(
1845            "req-1",
1846            "web_fetch",
1847            r#"{"url":"https://a"}"#,
1848            "",
1849            true,
1850            false,
1851            &[],
1852            "slack:T1:U9",
1853            "",
1854            "workspace-write",
1855            "",
1856            "",
1857            "conv-1",
1858            "nonce-1",
1859            &forged,
1860        );
1861        let mut v: serde_json::Value = serde_json::from_slice(&forged_payload).unwrap();
1862        v["signed_by"] = serde_json::json!(crate::hex::lower(&loaded.public_key_bytes()));
1863        assert!(
1864            verify_signed_response(v.to_string().as_bytes()).is_none(),
1865            "a from_seed(1) signature must not verify against the loaded key"
1866        );
1867    }
1868
1869    // #594: a grant_replay audit record round-trips through verification and any
1870    // tamper to a bound field flips verification to false.
1871    #[test]
1872    fn grant_replay_audit_is_signed_and_tamper_evident() {
1873        let signer = ApprovalSigner::from_seed(9);
1874        let covered = vec!["arbitrary-egress".to_owned()];
1875        let (payload, _sig, _pk) = grant_replay_payload(
1876            "conv-1",
1877            "turn-7",
1878            "post_summary",
1879            "deadbeef",
1880            &covered,
1881            "sha256:template-abc",
1882            &signer,
1883        );
1884        assert!(verify_grant_replay(&payload), "the genuine record verifies");
1885        // Tampering any bound field breaks the signature.
1886        for (field, val) in [
1887            ("conversation_id", serde_json::json!("conv-EVIL")),
1888            ("turn_id", serde_json::json!("turn-8")),
1889            ("tool", serde_json::json!("exfiltrate")),
1890            ("grant_ref", serde_json::json!("cafe")),
1891            (
1892                "covered_capabilities",
1893                serde_json::json!(["arbitrary-egress", "mutate-external"]),
1894            ),
1895            ("coverage_hash", serde_json::json!("sha256:other")),
1896        ] {
1897            let mut v: Value = serde_json::from_slice(&payload).unwrap();
1898            v[field] = val;
1899            assert!(
1900                !verify_grant_replay(v.to_string().as_bytes()),
1901                "tampered {field} must fail verification"
1902            );
1903        }
1904        assert!(!verify_grant_replay(b"not json"));
1905    }
1906
1907    // #595: the covered capability set is part of the signed contract — it
1908    // round-trips through verification and cannot be widened after signing.
1909    #[test]
1910    fn covered_capabilities_are_signed_and_tamper_evident() {
1911        let signer = ApprovalSigner::from_seed(42);
1912        let covered = vec!["arbitrary-egress".to_owned()];
1913        let (payload, _sig, _pk) = response_payload(
1914            "req-1",
1915            "web_fetch",
1916            r#"{"url":"https://a"}"#,
1917            "",
1918            true,
1919            true,
1920            &covered,
1921            "slack:T1:U9",
1922            "",
1923            "workspace-write",
1924            "",
1925            "",
1926            "conv-1",
1927            "nonce-1",
1928            &signer,
1929        );
1930        let verified = verify_signed_response(&payload).expect("verifies untampered");
1931        assert_eq!(verified.covered_capabilities, covered);
1932
1933        // Widening the covered set after signing invalidates the signature.
1934        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1935        v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
1936        assert!(
1937            verify_signed_response(v.to_string().as_bytes()).is_none(),
1938            "a tampered covered set must fail verification"
1939        );
1940        // So does shrinking it to hide what a grant covered.
1941        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1942        v["covered_capabilities"] = serde_json::json!([]);
1943        assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
1944    }
1945
1946    // #595: the request records the gate's capability shortfall and decodes
1947    // it back for the respond path; absent decodes empty (covers nothing).
1948    #[test]
1949    fn request_missing_capabilities_round_trip() {
1950        let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
1951        let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing);
1952        assert_eq!(decode_request_missing_capabilities(&bytes), missing);
1953        let bare = request_payload("call-2", "grep", "{}", "", "", &[]);
1954        assert_eq!(
1955            decode_request_missing_capabilities(&bare),
1956            Vec::<String>::new()
1957        );
1958        assert_eq!(
1959            decode_request_missing_capabilities(b"{\"nope\":1}"),
1960            Vec::<String>::new()
1961        );
1962    }
1963
1964    // #590: excision markers round-trip and are tamper-evident on every
1965    // covered field — widening positions, flipping scope, or re-targeting
1966    // the conversation all fail verification (taint stays, fail closed).
1967    #[test]
1968    fn signed_excision_round_trips_and_is_tamper_evident() {
1969        let signer = ApprovalSigner::from_seed(11);
1970        let (payload, _sig, _pk) = excision_payload(
1971            "conv-1",
1972            EXCISION_SCOPE_CASCADE,
1973            &[17, 23],
1974            "persona-9",
1975            "poisoned fetch",
1976            &signer,
1977        );
1978        let v = verify_signed_excision(&payload).expect("verifies untampered");
1979        assert_eq!(v.conversation_id, "conv-1");
1980        assert!(v.is_cascade());
1981        assert_eq!(v.positions, vec![17, 23]);
1982        assert_eq!(v.requested_by, "persona-9");
1983
1984        for (field, value) in [
1985            ("positions", serde_json::json!([17, 23, 40])),
1986            ("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
1987            ("conversation_id", serde_json::json!("conv-2")),
1988            ("requested_by", serde_json::json!("someone-else")),
1989        ] {
1990            let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1991            t[field] = value;
1992            assert!(
1993                verify_signed_excision(t.to_string().as_bytes()).is_none(),
1994                "tampered {field} must fail verification"
1995            );
1996        }
1997        // An unknown scope is refused even before the signature check.
1998        let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
1999        t["scope"] = serde_json::json!("everything");
2000        assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
2001        // Garbage is refused.
2002        assert!(verify_signed_excision(b"not json").is_none());
2003    }
2004
2005    #[test]
2006    fn signed_response_round_trips() {
2007        let signer = ApprovalSigner::from_seed(42);
2008        // A one-shot approval: approved_for_session = false.
2009        let (payload, _sig, _pk) = response_payload(
2010            "req-1",
2011            "rm",
2012            r#"{"path":"/etc"}"#,
2013            "",
2014            true,
2015            false,
2016            &[],
2017            "slack:T1:U9",
2018            "",
2019            "workspace-write",
2020            "looks fine",
2021            "",
2022            "conv-1",
2023            "nonce-1",
2024            &signer,
2025        );
2026        let verified =
2027            verify_signed_response(&payload).expect("signature verifies on untampered payload");
2028        assert!(verified.approved);
2029        assert_eq!(verified.reason, "looks fine");
2030        assert_eq!(verified.request_id, "req-1");
2031        assert_eq!(verified.tool_name, "rm");
2032        assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
2033        assert_eq!(verified.caller, "slack:T1:U9");
2034        assert_eq!(verified.conversation_id, "conv-1");
2035        assert_eq!(verified.nonce, "nonce-1");
2036        assert!(verified.approved);
2037        // A one-shot approval carries no session scope.
2038        assert!(!verified.approved_for_session);
2039    }
2040
2041    /// #1025 backward compatibility: a payload signed BEFORE the `approver`
2042    /// field existed (no `approver` key at all, not merely an empty one)
2043    /// must still decode and verify byte-for-byte identically — every
2044    /// `approval_response` ever persisted was signed this way, and they are
2045    /// re-verified on every replay.
2046    ///
2047    /// The pre-#1025 shape is recomputed inline (NOT a hardcoded byte/hex
2048    /// fixture) rather than pinned literally: `Value::to_string()`'s key
2049    /// order depends on `serde_json`'s `preserve_order` feature, which flips
2050    /// under Cargo feature unification depending on what else is in the
2051    /// build graph (`#345`) — a literal fixture captured under one build
2052    /// configuration silently fails under another that unifies the feature
2053    /// differently, even though nothing about the signed *content* changed.
2054    /// See `receipt_payload_pins_v2_canonical_shape`'s identical note.
2055    #[test]
2056    fn pre_approver_field_payload_still_verifies_unchanged() {
2057        let signer = ApprovalSigner::from_seed(42);
2058        let (request_id, tool_name, args_json, modified_args_json) =
2059            ("req-1", "tool.name", r#"{"a":1}"#, "");
2060        let (approved, approved_for_session) = (true, true);
2061        let covered_capabilities = ["cap.a".to_owned(), "cap.b".to_owned()];
2062        let caller = "persona-caller";
2063        let sandbox_mode = "sandboxed";
2064        let reason = "looks fine";
2065        let injected_context = "";
2066        let conversation_id = "conv-1";
2067        let nonce = "nonce-1";
2068
2069        // The exact pre-#1025 canonical/payload shape — the 13 fields
2070        // `response_canonical`/`response_payload` signed before `approver`
2071        // existed, recomputed here with the SAME macro call so both sides
2072        // agree under whatever key ordering is in effect for this build.
2073        let pre_approver_canonical = serde_json::json!({
2074            "request_id": request_id,
2075            "tool_name": tool_name,
2076            "args_json": args_json,
2077            "modified_args_json": modified_args_json,
2078            "approved": approved,
2079            "approved_for_session": approved_for_session,
2080            "covered_capabilities": covered_capabilities,
2081            "caller": caller,
2082            "sandbox_mode": sandbox_mode,
2083            "reason": reason,
2084            "injected_context": injected_context,
2085            "conversation_id": conversation_id,
2086            "nonce": nonce,
2087        });
2088        let expected_sig = signer.sign(pre_approver_canonical.to_string().as_bytes());
2089        let expected_pk = signer.public_key_bytes();
2090        let pre_approver_payload = serde_json::json!({
2091            "request_id": request_id,
2092            "tool_name": tool_name,
2093            "args_json": args_json,
2094            "modified_args_json": modified_args_json,
2095            "approved": approved,
2096            "approved_for_session": approved_for_session,
2097            "covered_capabilities": covered_capabilities,
2098            "caller": caller,
2099            "sandbox_mode": sandbox_mode,
2100            "reason": reason,
2101            "injected_context": injected_context,
2102            "conversation_id": conversation_id,
2103            "nonce": nonce,
2104            "signed_by": crate::hex::lower(&expected_pk),
2105            "signature_hex": crate::hex::lower(&expected_sig),
2106        })
2107        .to_string()
2108        .into_bytes();
2109
2110        let verified = verify_signed_response(&pre_approver_payload)
2111            .expect("a pre-#1025 payload must still verify");
2112        assert_eq!(verified.request_id, request_id);
2113        assert_eq!(verified.caller, caller);
2114        assert_eq!(
2115            verified.approver, "",
2116            "no approver field existed on this payload — decodes to empty, not an error"
2117        );
2118
2119        // Re-signing the SAME inputs with today's code (empty approver) must
2120        // reproduce byte-identical output — the additive field is omitted
2121        // from the canonical entirely when empty, not merely defaulted.
2122        let (regenerated, sig, pk) = response_payload(
2123            request_id,
2124            tool_name,
2125            args_json,
2126            modified_args_json,
2127            approved,
2128            approved_for_session,
2129            &covered_capabilities,
2130            caller,
2131            "",
2132            sandbox_mode,
2133            reason,
2134            injected_context,
2135            conversation_id,
2136            nonce,
2137            &signer,
2138        );
2139        assert_eq!(
2140            regenerated, pre_approver_payload,
2141            "an empty approver must produce byte-identical canonical/payload to before #1025"
2142        );
2143        assert_eq!(
2144            sig, expected_sig,
2145            "an empty approver must sign byte-identically to before #1025"
2146        );
2147        assert_eq!(pk, expected_pk, "public key must be unchanged");
2148    }
2149
2150    #[test]
2151    fn session_response_round_trips_with_caller_binding() {
2152        let signer = ApprovalSigner::from_seed(42);
2153        let (payload, _sig, _pk) = response_payload(
2154            "req-1",
2155            "grep",
2156            r#"{"pattern":"x"}"#,
2157            "",
2158            true,
2159            true,
2160            &[],
2161            "slack:T1:U9",
2162            "",
2163            "workspace-write",
2164            "remember it",
2165            "",
2166            "conv-1",
2167            "nonce-1",
2168            &signer,
2169        );
2170        let verified = verify_signed_response(&payload).expect("session signature verifies");
2171        assert!(verified.approved);
2172        assert!(verified.approved_for_session, "carries session scope");
2173        assert_eq!(verified.caller, "slack:T1:U9");
2174        assert_eq!(verified.tool_name, "grep");
2175        assert!(verified.approved);
2176    }
2177
2178    #[test]
2179    fn tampered_session_or_caller_fails_verification() {
2180        let signer = ApprovalSigner::from_seed(42);
2181        let (payload, _sig, _pk) = response_payload(
2182            "req-1",
2183            "grep",
2184            "{}",
2185            "",
2186            true,
2187            true,
2188            &[],
2189            "slack:T1:U9",
2190            "",
2191            "workspace-write",
2192            "ok",
2193            "",
2194            "conv-1",
2195            "nonce-1",
2196            &signer,
2197        );
2198        // Every signed field is covered: re-scoping the memory to another user,
2199        // flipping the session flag, swapping the tool, re-targeting the
2200        // conversation, or re-rolling the nonce must all fail.
2201        for (field, val) in [
2202            ("caller", Value::String("slack:T1:ATTACKER".to_owned())),
2203            ("approved_for_session", Value::Bool(false)),
2204            ("tool_name", Value::String("rm".to_owned())),
2205            ("approved", Value::Bool(false)),
2206            ("args_json", Value::String("EVIL".to_owned())),
2207            // The approver's edit and injected context are signed too: forging
2208            // either — swapping in different execution args, or a different
2209            // injected instruction — must invalidate the signature.
2210            (
2211                "modified_args_json",
2212                Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
2213            ),
2214            ("injected_context", Value::String("do EVIL".to_owned())),
2215            (
2216                "sandbox_mode",
2217                Value::String("danger-full-access".to_owned()),
2218            ),
2219            ("conversation_id", Value::String("conv-OTHER".to_owned())),
2220            ("nonce", Value::String("nonce-OTHER".to_owned())),
2221        ] {
2222            let mut v: Value = serde_json::from_slice(&payload).unwrap();
2223            v[field] = val;
2224            assert!(
2225                verify_signed_response(&v.to_string().into_bytes()).is_none(),
2226                "tampering with {field} must fail verification"
2227            );
2228        }
2229    }
2230
2231    /// #67 gate A: an approver can approve with EDITED args. The signed response
2232    /// carries both the model's PROPOSED args (`args_json`, the identity binding)
2233    /// and the approver's EDIT (`modified_args_json`, what executes). Both are
2234    /// covered by the signature. Crucially, the identity binding
2235    /// ([`VerifiedResponse::authorizes_call`]) still matches the PROPOSED args —
2236    /// so a model that re-emits a different call on resume cannot inherit the
2237    /// approval — while the edit is a separate signed field the executor
2238    /// substitutes. The pure resolver (`polyc_agent::resolve_approved_call`) owns
2239    /// the "empty edit ⇒ run proposed" defaulting; here we only pin the crypto
2240    /// contract: both fields round-trip, and identity binds the proposed args.
2241    #[test]
2242    fn edited_response_binds_proposed_and_carries_modified() {
2243        let signer = ApprovalSigner::from_seed(7);
2244        let proposed = r#"{"path":"/etc/shadow"}"#;
2245        let edited = r#"{"path":"/etc/hostname"}"#;
2246        let (payload, _sig, _pk) = response_payload(
2247            "call-1",
2248            "read_file",
2249            proposed,
2250            edited,
2251            true,
2252            false,
2253            &[],
2254            "slack:T1:U9",
2255            "",
2256            "workspace-write",
2257            "narrowed the path",
2258            "",
2259            "conv-1",
2260            "nonce-1",
2261            &signer,
2262        );
2263        let v = verify_signed_response(&payload).expect("edited approval verifies");
2264        assert_eq!(v.args_json, proposed, "identity binds the proposed args");
2265        assert_eq!(
2266            v.modified_args_json, edited,
2267            "the edit is carried and signed"
2268        );
2269        // Identity binding is against the PROPOSED args — this is what the model
2270        // must re-present on resume; the edit is not part of the identity.
2271        assert!(
2272            v.authorizes_call("call-1", "read_file", proposed),
2273            "the exact proposed call is authorized"
2274        );
2275        assert!(
2276            !v.authorizes_call("call-1", "read_file", edited),
2277            "the edited args are NOT the identity — authorizes_call binds proposed"
2278        );
2279    }
2280
2281    /// #67 gate A: an unedited approval carries an empty `modified_args_json` and
2282    /// still authorizes exactly the proposed call — behaviourally identical to the
2283    /// pre-#67 approve path, so the common case is unchanged.
2284    #[test]
2285    fn unedited_response_carries_empty_edit() {
2286        let signer = ApprovalSigner::from_seed(7);
2287        let proposed = r#"{"path":"/tmp/x"}"#;
2288        let (payload, _sig, _pk) = response_payload(
2289            "call-1",
2290            "read_file",
2291            proposed,
2292            "",
2293            true,
2294            false,
2295            &[],
2296            "slack:T1:U9",
2297            "",
2298            "workspace-write",
2299            "ok",
2300            "",
2301            "conv-1",
2302            "nonce-1",
2303            &signer,
2304        );
2305        let v = verify_signed_response(&payload).expect("verifies");
2306        assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
2307        assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
2308        assert!(v.authorizes_call("call-1", "read_file", proposed));
2309    }
2310
2311    /// #67 gate A: an approver can attach context to inject before the tool runs.
2312    /// The injected context is a signed field that round-trips.
2313    #[test]
2314    fn injected_context_round_trips_and_is_signed() {
2315        let signer = ApprovalSigner::from_seed(7);
2316        let (payload, _sig, _pk) = response_payload(
2317            "call-1",
2318            "shell",
2319            r#"{"cmd":"ls"}"#,
2320            "",
2321            true,
2322            false,
2323            &[],
2324            "slack:T1:U9",
2325            "",
2326            "workspace-write",
2327            "ok",
2328            "only touch files under src/",
2329            "conv-1",
2330            "nonce-1",
2331            &signer,
2332        );
2333        let v = verify_signed_response(&payload).expect("verifies");
2334        assert_eq!(v.injected_context, "only touch files under src/");
2335    }
2336
2337    /// #67 (#539/#540): a dispatch-mutation record round-trips and verifies;
2338    /// tampering with any covered field — including the kind — fails.
2339    #[test]
2340    fn mutation_round_trips_and_tamper_fails() {
2341        let signer = ApprovalSigner::from_seed(7);
2342        let (payload, _s, _p) = mutation_payload(
2343            "tool_input_rewrite",
2344            "call-1",
2345            "shell",
2346            "conv-1",
2347            r#"{"cmd":"rm -rf /"}"#,
2348            r#"{"cmd":"rm /tmp/x"}"#,
2349            &signer,
2350        );
2351        assert_eq!(
2352            verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
2353            Some((
2354                "tool_input_rewrite".to_owned(),
2355                r#"{"cmd":"rm -rf /"}"#.to_owned(),
2356                r#"{"cmd":"rm /tmp/x"}"#.to_owned()
2357            ))
2358        );
2359        for field in [
2360            "kind",
2361            "tool_call_id",
2362            "tool_name",
2363            "conversation_id",
2364            "before",
2365            "after",
2366        ] {
2367            let mut v: Value = serde_json::from_slice(&payload).unwrap();
2368            v[field] = Value::String("EVIL".to_owned());
2369            assert!(
2370                verify_mutation(&v.to_string().into_bytes()).is_none(),
2371                "tampering with {field} must fail"
2372            );
2373        }
2374    }
2375
2376    /// #67 (#538): a deferred "send back" round-trips and verifies; tampering
2377    /// with any covered field fails.
2378    #[test]
2379    fn deferred_round_trips_and_tamper_fails() {
2380        let signer = ApprovalSigner::from_seed(7);
2381        let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
2382        assert_eq!(
2383            verify_deferred(&payload),
2384            Some((
2385                "call-1".to_owned(),
2386                "conv-1".to_owned(),
2387                "need more info".to_owned()
2388            ))
2389        );
2390        for field in ["request_id", "conversation_id", "reason"] {
2391            let mut v: Value = serde_json::from_slice(&payload).unwrap();
2392            v[field] = Value::String("EVIL".to_owned());
2393            assert!(
2394                verify_deferred(&v.to_string().into_bytes()).is_none(),
2395                "tampering with {field} must fail"
2396            );
2397        }
2398    }
2399
2400    #[test]
2401    fn wire_verification_round_trips_and_binds_session_and_caller() {
2402        let signer = ApprovalSigner::from_seed(7);
2403        let (payload, _sig, _pk) = response_payload(
2404            "req-x",
2405            "grep",
2406            r#"{"p":"x"}"#,
2407            "",
2408            true,
2409            true,
2410            &[],
2411            "slack:T1:U9",
2412            "",
2413            "workspace-write",
2414            "go",
2415            "",
2416            "conv-7",
2417            "nonce-7",
2418            &signer,
2419        );
2420        let d = decode_response_full(&payload).expect("decoded payload");
2421        // decode_response_full surfaces every signed field.
2422        assert!(d.approved_for_session);
2423        assert_eq!(d.caller, "slack:T1:U9");
2424        assert_eq!(d.sandbox_mode, "workspace-write");
2425        assert_eq!(d.conversation_id, "conv-7");
2426        assert_eq!(d.nonce, "nonce-7");
2427        assert!(verify_wire_response(
2428            &d.request_id,
2429            &d.tool_name,
2430            &d.args_json,
2431            "",
2432            d.approved,
2433            d.approved_for_session,
2434            &[],
2435            &d.caller,
2436            &d.approver,
2437            &d.sandbox_mode,
2438            &d.reason,
2439            "",
2440            &d.conversation_id,
2441            &d.nonce,
2442            &d.signer_pk_hex,
2443            &d.signature_hex
2444        ));
2445        // Re-scoping the remembered grant to a different caller over the wire
2446        // must fail — the caller is covered by the signature.
2447        assert!(!verify_wire_response(
2448            &d.request_id,
2449            &d.tool_name,
2450            &d.args_json,
2451            "",
2452            d.approved,
2453            d.approved_for_session,
2454            &[],
2455            "slack:T1:ATTACKER",
2456            &d.approver,
2457            &d.sandbox_mode,
2458            &d.reason,
2459            "",
2460            &d.conversation_id,
2461            &d.nonce,
2462            &d.signer_pk_hex,
2463            &d.signature_hex
2464        ));
2465        // Tampering with the bound args over the wire invalidates the sig.
2466        assert!(!verify_wire_response(
2467            &d.request_id,
2468            &d.tool_name,
2469            r#"{"p":"EVIL"}"#,
2470            "",
2471            d.approved,
2472            d.approved_for_session,
2473            &[],
2474            &d.caller,
2475            &d.approver,
2476            &d.sandbox_mode,
2477            &d.reason,
2478            "",
2479            &d.conversation_id,
2480            &d.nonce,
2481            &d.signer_pk_hex,
2482            &d.signature_hex
2483        ));
2484        // Replaying the token into a DIFFERENT conversation over the wire must
2485        // fail — `conversation_id` is covered by the signature (#370, #77 3B).
2486        assert!(!verify_wire_response(
2487            &d.request_id,
2488            &d.tool_name,
2489            &d.args_json,
2490            "",
2491            d.approved,
2492            d.approved_for_session,
2493            &[],
2494            &d.caller,
2495            &d.approver,
2496            &d.sandbox_mode,
2497            &d.reason,
2498            "",
2499            "conv-OTHER",
2500            &d.nonce,
2501            &d.signer_pk_hex,
2502            &d.signature_hex
2503        ));
2504    }
2505
2506    /// #1025: `approver` is a distinct fact from `caller`, recoverable from a
2507    /// signed response and covered by the signature — the same-person and
2508    /// different-person (admin-approves-for-someone-else) cases both round-trip
2509    /// correctly, and the two identities are never conflated.
2510    #[test]
2511    fn approver_is_recoverable_and_distinct_from_caller() {
2512        let signer = ApprovalSigner::from_seed(3);
2513
2514        // Same-person: the caller approving their own paused turn signs an
2515        // approver equal to caller. Distinguishable by field, still equal by
2516        // value — this is the common case, not a degenerate one.
2517        let (self_approved, ..) = response_payload(
2518            "req-1",
2519            "grep",
2520            r#"{"q":"x"}"#,
2521            "",
2522            true,
2523            false,
2524            &[],
2525            "slack:T1:U9",
2526            "slack:T1:U9",
2527            "workspace-write",
2528            "self-approved",
2529            "",
2530            "conv-1",
2531            "nonce-1",
2532            &signer,
2533        );
2534        let self_decoded = decode_response_full(&self_approved).expect("decodes");
2535        assert_eq!(self_decoded.caller, "slack:T1:U9");
2536        assert_eq!(self_decoded.approver, "slack:T1:U9");
2537        let self_verified = verify_signed_response(&self_approved).expect("verifies");
2538        assert_eq!(self_verified.caller, self_verified.approver);
2539
2540        // Different-person: an admin approves on behalf of the turn's own
2541        // beneficiary. `caller` stays the beneficiary (principal_ref's own
2542        // resume-attribution source, per the sibling #1024 fix); `approver` is
2543        // the admin — a genuinely different persona, both recoverable and
2544        // distinguishable from the same signed record.
2545        let (admin_approved, ..) = response_payload(
2546            "req-2",
2547            "rm",
2548            r#"{"path":"/tmp/x"}"#,
2549            "",
2550            true,
2551            false,
2552            &[],
2553            "slack:T1:BENEFICIARY",
2554            "slack:T1:ADMIN",
2555            "workspace-write",
2556            "approved on your behalf",
2557            "",
2558            "conv-2",
2559            "nonce-2",
2560            &signer,
2561        );
2562        let admin_decoded = decode_response_full(&admin_approved).expect("decodes");
2563        assert_eq!(admin_decoded.caller, "slack:T1:BENEFICIARY");
2564        assert_eq!(admin_decoded.approver, "slack:T1:ADMIN");
2565        assert_ne!(
2566            admin_decoded.caller, admin_decoded.approver,
2567            "admin-approves-for-someone-else must decode two DISTINCT identities"
2568        );
2569        let admin_verified = verify_signed_response(&admin_approved).expect("verifies");
2570        assert_eq!(admin_verified.caller, "slack:T1:BENEFICIARY");
2571        assert_eq!(admin_verified.approver, "slack:T1:ADMIN");
2572
2573        // Tampering with the signed approver (re-scoping the policy/audit fact
2574        // to a different identity post-signing) invalidates the signature —
2575        // it is covered exactly like `caller`.
2576        let mut v: serde_json::Value = serde_json::from_slice(&admin_approved).unwrap();
2577        v["approver"] = serde_json::json!("slack:T1:ATTACKER");
2578        assert!(
2579            verify_signed_response(v.to_string().as_bytes()).is_none(),
2580            "a tampered approver must fail verification"
2581        );
2582    }
2583
2584    /// `#377` core invariant: a reviewer auto-approval signs a payload that is
2585    /// BYTE-IDENTICAL to the one a human signs for the same decision, yet is
2586    /// distinguishable in the audit log by its signed `reason`.
2587    ///
2588    /// There is a single signing function ([`response_payload`]); the reviewer
2589    /// path is just that function with `approved == true`,
2590    /// `approved_for_session == false`, and an auto-review `reason`. We pin two
2591    /// properties:
2592    ///   1. With every argument INCLUDING the reason held equal, the auto and
2593    ///      human calls produce identical bytes + signature — proving the auto
2594    ///      path adds no hidden field and shares the human signing contract
2595    ///      exactly (guards against a future forked auto-signer).
2596    ///   2. With the auto-review reason, the response still verifies, still
2597    ///      carries `approved && !approved_for_session`, and is flagged by
2598    ///      [`is_auto_review_reason`] while a human reason is not — so the event
2599    ///      log can tell machine from human consent off the signed field alone.
2600    ///   3. Substitution resistance: flipping ANY one bound field — the tool, the
2601    ///      args, the beneficiary caller, or the request id — changes both the
2602    ///      canonical bytes AND the signature, so a forged or substituted
2603    ///      approval (signed for one call, replayed to authorize another) cannot
2604    ///      match. Without this, (1)'s byte-identical property would be vacuous.
2605    #[test]
2606    fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
2607        let signer = ApprovalSigner::from_seed(7);
2608        let (rid, tool, args, caller, mode, conv, nonce) = (
2609            "req-9",
2610            "file_read",
2611            r#"{"path":"a.txt"}"#,
2612            "slack:T1:U9",
2613            "read-only",
2614            "conv-9",
2615            "nonce-9",
2616        );
2617
2618        // (1) Same decision + same reason via the one signing path ⇒ identical
2619        // bytes regardless of which side "produced" it. The reviewer is not a
2620        // separate signer; it cannot diverge structurally from the human path.
2621        let shared_reason = auto_review_reason("low");
2622        let human_like = response_payload(
2623            rid,
2624            tool,
2625            args,
2626            "",
2627            true,
2628            false,
2629            &[],
2630            caller,
2631            "",
2632            mode,
2633            &shared_reason,
2634            "",
2635            conv,
2636            nonce,
2637            &signer,
2638        );
2639        let reviewer = response_payload(
2640            rid,
2641            tool,
2642            args,
2643            "",
2644            true,
2645            false,
2646            &[],
2647            caller,
2648            "",
2649            mode,
2650            &shared_reason,
2651            "",
2652            conv,
2653            nonce,
2654            &signer,
2655        );
2656        assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
2657        assert_eq!(human_like.1, reviewer.1, "signature must be identical");
2658
2659        // (2) The auto-review response verifies and is an approve-once decision.
2660        let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
2661        assert!(verified.approved);
2662        assert!(
2663            !verified.approved_for_session,
2664            "a machine decision is never remembered per-caller"
2665        );
2666        assert!(
2667            is_auto_review_reason(&verified.reason),
2668            "the signed reason marks this as an auto-approval"
2669        );
2670
2671        // A genuine human approval over the same call is NOT flagged as auto —
2672        // the distinguisher reads the signed reason, so it is unforgeable.
2673        let (human_payload, _s, _p) = response_payload(
2674            rid,
2675            tool,
2676            args,
2677            "",
2678            true,
2679            false,
2680            &[],
2681            caller,
2682            "",
2683            mode,
2684            "looks fine",
2685            "",
2686            conv,
2687            nonce,
2688            &signer,
2689        );
2690        let human = verify_signed_response(&human_payload).expect("human verifies");
2691        assert!(!is_auto_review_reason(&human.reason));
2692
2693        // The two payloads differ ONLY in the reason field — every bound
2694        // identity / decision / scope field is byte-equal, which is what makes
2695        // the auto path indistinguishable from a human one except by reason.
2696        let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
2697        let h: Value = serde_json::from_slice(&human_payload).unwrap();
2698        for field in [
2699            "request_id",
2700            "tool_name",
2701            "args_json",
2702            "modified_args_json",
2703            "approved",
2704            "approved_for_session",
2705            "caller",
2706            "sandbox_mode",
2707            "injected_context",
2708            "conversation_id",
2709            "nonce",
2710        ] {
2711            assert_eq!(a[field], h[field], "{field} must match the human payload");
2712        }
2713        assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
2714
2715        // (3) Substitution resistance: each variant holds every input equal to
2716        // `reviewer` and flips exactly ONE bound field. A different tool / args /
2717        // caller / request_id / conversation / nonce must change BOTH the
2718        // canonical bytes and the signature — so an approval signed for
2719        // `(req-9, file_read, a.txt, U9, conv-9, nonce-9)` can never be replayed to
2720        // authorize a write, a different path, a different beneficiary, a
2721        // different CONVERSATION (#370, #77 3B), or re-presented under a new nonce.
2722        // This is what makes (1)'s "byte-identical for identical inputs" a
2723        // security property and not just determinism.
2724        let base = &reviewer.0;
2725        let base_sig = &reviewer.1;
2726        for (label, variant) in [
2727            (
2728                "tool",
2729                response_payload(
2730                    rid,
2731                    "file_write",
2732                    args,
2733                    "",
2734                    true,
2735                    false,
2736                    &[],
2737                    caller,
2738                    "",
2739                    mode,
2740                    &shared_reason,
2741                    "",
2742                    conv,
2743                    nonce,
2744                    &signer,
2745                ),
2746            ),
2747            (
2748                "args",
2749                response_payload(
2750                    rid,
2751                    tool,
2752                    r#"{"path":"b.txt"}"#,
2753                    "",
2754                    true,
2755                    false,
2756                    &[],
2757                    caller,
2758                    "",
2759                    mode,
2760                    &shared_reason,
2761                    "",
2762                    conv,
2763                    nonce,
2764                    &signer,
2765                ),
2766            ),
2767            (
2768                "caller",
2769                response_payload(
2770                    rid,
2771                    tool,
2772                    args,
2773                    "",
2774                    true,
2775                    false,
2776                    &[],
2777                    "slack:T1:UEVIL",
2778                    "",
2779                    mode,
2780                    &shared_reason,
2781                    "",
2782                    conv,
2783                    nonce,
2784                    &signer,
2785                ),
2786            ),
2787            (
2788                "request_id",
2789                response_payload(
2790                    "req-OTHER",
2791                    tool,
2792                    args,
2793                    "",
2794                    true,
2795                    false,
2796                    &[],
2797                    caller,
2798                    "",
2799                    mode,
2800                    &shared_reason,
2801                    "",
2802                    conv,
2803                    nonce,
2804                    &signer,
2805                ),
2806            ),
2807            (
2808                "conversation_id",
2809                response_payload(
2810                    rid,
2811                    tool,
2812                    args,
2813                    "",
2814                    true,
2815                    false,
2816                    &[],
2817                    caller,
2818                    "",
2819                    mode,
2820                    &shared_reason,
2821                    "",
2822                    "conv-OTHER",
2823                    nonce,
2824                    &signer,
2825                ),
2826            ),
2827            (
2828                "nonce",
2829                response_payload(
2830                    rid,
2831                    tool,
2832                    args,
2833                    "",
2834                    true,
2835                    false,
2836                    &[],
2837                    caller,
2838                    "",
2839                    mode,
2840                    &shared_reason,
2841                    "",
2842                    conv,
2843                    "nonce-OTHER",
2844                    &signer,
2845                ),
2846            ),
2847        ] {
2848            assert_ne!(
2849                &variant.0, base,
2850                "{label}: a different {label} must change the canonical bytes"
2851            );
2852            assert_ne!(
2853                &variant.1, base_sig,
2854                "{label}: a different {label} must change the signature"
2855            );
2856        }
2857    }
2858
2859    /// `#370` (a): a capability token signed for one conversation is REJECTED
2860    /// when presented for another. The `conversation_id` is covered by the
2861    /// signature, so it cannot be re-targeted without invalidating it; the
2862    /// consume gate ([`verify_capability`]) refuses any token whose signed
2863    /// conversation does not match the one it is being consumed in. Closes `#77`
2864    /// bug 3B (one signed response valid across conversations sharing a
2865    /// `request_id`).
2866    #[test]
2867    fn capability_rejected_across_conversations() {
2868        let signer = ApprovalSigner::from_seed(11);
2869        let (payload, _sig, _pk) = response_payload(
2870            "call-0",
2871            "delete_file",
2872            r#"{"path":"/etc/hosts"}"#,
2873            "",
2874            true,
2875            false,
2876            &[],
2877            "slack:T1:U9",
2878            "",
2879            "workspace-write",
2880            "ok",
2881            "",
2882            "conv-A",
2883            "nonce-A",
2884            &signer,
2885        );
2886        let consumed = HashSet::new();
2887        // Same conversation, fresh nonce ⇒ honored.
2888        assert!(
2889            verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
2890                .is_some(),
2891            "a token must verify in the conversation it was signed for"
2892        );
2893        // Different conversation ⇒ rejected, even though request_id/tool/args
2894        // are byte-identical (the #77 3B replay).
2895        assert!(
2896            verify_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
2897                .is_none(),
2898            "a token signed for conv-A must be rejected when consumed in conv-B"
2899        );
2900    }
2901
2902    /// `#370` (b): a single-use token is REJECTED on a second presentation. The
2903    /// consumer records the token's `nonce` after honoring it; a re-presentation
2904    /// of the SAME signed bytes (a captured/replayed token) is then refused.
2905    #[test]
2906    fn capability_is_single_use() {
2907        let signer = ApprovalSigner::from_seed(11);
2908        let (payload, _sig, _pk) = response_payload(
2909            "call-0",
2910            "delete_file",
2911            r#"{"path":"/etc/hosts"}"#,
2912            "",
2913            true,
2914            false,
2915            &[],
2916            "slack:T1:U9",
2917            "",
2918            "workspace-write",
2919            "ok",
2920            "",
2921            "conv-A",
2922            "nonce-A",
2923            &signer,
2924        );
2925        let mut consumed = HashSet::new();
2926        // First presentation is honored and yields the bound nonce.
2927        let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
2928            .expect("first use honored");
2929        assert_eq!(v.nonce, "nonce-A");
2930        consumed.insert(v.nonce.clone());
2931        // Second presentation of the same token is rejected — single-use.
2932        assert!(
2933            verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
2934                .is_none(),
2935            "a spent token must be rejected on re-presentation"
2936        );
2937    }
2938
2939    /// `#370` (c)/(d): the token is ARGS-BOUND. A verified, approved token
2940    /// authorizes ONLY the exact `(request_id, tool_name, args_json)` it was
2941    /// signed for; a call with different args (a captured approval reused with a
2942    /// new payload) is NOT authorized, while the matching call IS.
2943    #[test]
2944    fn capability_authorizes_only_matching_args() {
2945        let signer = ApprovalSigner::from_seed(11);
2946        let (payload, _sig, _pk) = response_payload(
2947            "call-0",
2948            "delete_file",
2949            r#"{"path":"/tmp/scratch"}"#,
2950            "",
2951            true,
2952            false,
2953            &[],
2954            "slack:T1:U9",
2955            "",
2956            "workspace-write",
2957            "ok",
2958            "",
2959            "conv-A",
2960            "nonce-A",
2961            &signer,
2962        );
2963        let consumed = HashSet::new();
2964        let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
2965            .expect("verifies in conv-A");
2966        // (c) different args ⇒ NOT authorized.
2967        assert!(
2968            !v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
2969            "a token must not authorize a call with different args"
2970        );
2971        // …nor a different tool with the same id.
2972        assert!(
2973            !v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
2974            "a token must not authorize a different tool"
2975        );
2976        // (d) the exact signed call ⇒ authorized (happy path).
2977        assert!(
2978            v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
2979            "the exact signed call must be authorized"
2980        );
2981    }
2982
2983    /// A denial never authorizes a call, regardless of identity match.
2984    #[test]
2985    fn denied_capability_authorizes_nothing() {
2986        let signer = ApprovalSigner::from_seed(11);
2987        let (payload, _sig, _pk) = response_payload(
2988            "call-0",
2989            "delete_file",
2990            "{}",
2991            "",
2992            false,
2993            false,
2994            &[],
2995            "slack:T1:U9",
2996            "",
2997            "workspace-write",
2998            "deny",
2999            "",
3000            "conv-A",
3001            "nonce-A",
3002            &signer,
3003        );
3004        let consumed = HashSet::new();
3005        let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
3006            .expect("verifies");
3007        assert!(!v.approved);
3008        assert!(
3009            !v.authorizes_call("call-0", "delete_file", "{}"),
3010            "a denied token authorizes nothing even on an exact identity match"
3011        );
3012    }
3013
3014    #[test]
3015    fn request_payload_round_trips_id() {
3016        let bytes = request_payload(
3017            "call-7",
3018            "rm",
3019            r#"{"path":"/etc"}"#,
3020            "workspace-write",
3021            "",
3022            &[],
3023        );
3024        assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
3025        // An ordinary gated call carries no override reason.
3026        assert_eq!(decode_request_reason(&bytes), "");
3027    }
3028
3029    #[test]
3030    fn request_payload_carries_override_reason() {
3031        // The lethal-trifecta override reason rides the durable approval_request
3032        // so the signed log records WHY a trifecta-gated call was paused.
3033        let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
3034        let bytes = request_payload(
3035            "call-9",
3036            "web_fetch",
3037            r#"{"url":"https://x"}"#,
3038            "",
3039            reason,
3040            &[],
3041        );
3042        assert_eq!(decode_request_reason(&bytes), reason);
3043        // The existing scalar fields still decode unchanged.
3044        assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
3045        assert_eq!(
3046            decode_request_fields(&bytes),
3047            Some((
3048                "call-9".to_owned(),
3049                "web_fetch".to_owned(),
3050                r#"{"url":"https://x"}"#.to_owned()
3051            ))
3052        );
3053    }
3054
3055    /// Golden test: `receipt_payload` must produce the EXACT **v2** signed-payload
3056    /// bytes — `version` + the six settlement facts + the four binding fields,
3057    /// in that order — plus the two uncovered signature fields. We recompute the
3058    /// expected canonical+full JSON inline and assert the struct form is
3059    /// byte-identical, pinning the v2 key set, order, and signature so a future
3060    /// edit cannot silently reorder, rename, or swap a covered field. Recomputing
3061    /// inline (rather than hardcoding bytes) keeps the golden stable across
3062    /// `serde_json` feature unification (e.g. `preserve_order`), which only flips
3063    /// key order — what matters is that both forms agree under whatever ordering
3064    /// is in effect.
3065    #[test]
3066    fn receipt_payload_pins_v2_canonical_shape() {
3067        let signer = ApprovalSigner::from_seed(99);
3068        let (reference, amount, currency, recipient, method, timestamp) = (
3069            "tx-abc",
3070            "0.01",
3071            "USDC",
3072            "0xrecipient",
3073            "tempo",
3074            "2026-06-02T00:00:00Z",
3075        );
3076        let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
3077            "outbound_payment_receipt",
3078            "call-1",
3079            "42",
3080            "abcd1234",
3081            "conv-xyz",
3082        );
3083
3084        // The exact v2 JSON the implementation must build, recomputed here.
3085        let expected_canonical = serde_json::json!({
3086            "version": RECEIPT_VERSION,
3087            "kind": kind,
3088            "reference": reference,
3089            "amount": amount,
3090            "currency": currency,
3091            "recipient": recipient,
3092            "method": method,
3093            "timestamp": timestamp,
3094            "tool_call_id": tool_call_id,
3095            "approval_pos": approval_pos,
3096            "approved_args_hash": approved_args_hash,
3097            "subject": subject,
3098        });
3099        let expected_sig = signer.sign(expected_canonical.to_string().as_bytes());
3100        let expected_pk = signer.public_key_bytes();
3101        let expected_full = serde_json::json!({
3102            "version": RECEIPT_VERSION,
3103            "kind": kind,
3104            "reference": reference,
3105            "amount": amount,
3106            "currency": currency,
3107            "recipient": recipient,
3108            "method": method,
3109            "timestamp": timestamp,
3110            "tool_call_id": tool_call_id,
3111            "approval_pos": approval_pos,
3112            "approved_args_hash": approved_args_hash,
3113            "subject": subject,
3114            "signed_by": crate::hex::lower(&expected_pk),
3115            "signature_hex": crate::hex::lower(&expected_sig),
3116        })
3117        .to_string();
3118
3119        let (payload, sig, pk) = receipt_payload(
3120            &ReceiptPayload {
3121                kind,
3122                reference,
3123                amount,
3124                currency,
3125                recipient,
3126                method,
3127                timestamp,
3128                tool_call_id,
3129                approval_pos,
3130                approved_args_hash,
3131                subject,
3132            },
3133            &signer,
3134        );
3135
3136        assert_eq!(
3137            String::from_utf8(payload).unwrap(),
3138            expected_full,
3139            "v2 receipt payload must be byte-identical to the pinned v2 shape"
3140        );
3141        assert_eq!(
3142            sig, expected_sig,
3143            "signature must match the pinned v2 shape"
3144        );
3145        assert_eq!(pk, expected_pk, "public key must be unchanged");
3146    }
3147
3148    /// Single-source guard: both the signing path (`receipt_payload`) and the
3149    /// verifying path (`verify_signed_receipt`) MUST derive their canonical
3150    /// signed JSON from the one [`ReceiptPayload::canonical_json`] builder, so
3151    /// the signed field set/order cannot drift between sign and verify.
3152    ///
3153    /// We assert the canonical bytes the signer commits to are exactly the
3154    /// bytes `canonical_json` produces for the same fields, and that a receipt
3155    /// reconstructed from `VerifiedReceipt` (the verify path's owned form)
3156    /// yields the identical canonical bytes. If a future edit added a field to
3157    /// one json! block but not the other, those bytes would differ and this
3158    /// (plus the round-trip) would fail.
3159    #[test]
3160    fn receipt_sign_and_verify_share_one_canonical_source() {
3161        let signer = ApprovalSigner::from_seed(99);
3162        let trusted = vec![signer.public_key_bytes()];
3163        let fields = ReceiptPayload {
3164            kind: "outbound_payment_receipt",
3165            reference: "tx-abc",
3166            amount: "0.01",
3167            currency: "USDC",
3168            recipient: "0xrecipient",
3169            method: "tempo",
3170            timestamp: "2026-06-02T00:00:00Z",
3171            tool_call_id: "call-1",
3172            approval_pos: "42",
3173            approved_args_hash: "abcd1234",
3174            subject: "conv-xyz",
3175        };
3176
3177        // The signed bytes the producer commits to.
3178        let canonical_bytes = fields.canonical_json().to_string().into_bytes();
3179        let expected_sig = signer.sign(&canonical_bytes);
3180        let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
3181        assert_eq!(
3182            sig, expected_sig,
3183            "receipt_payload must sign exactly ReceiptPayload::canonical_json"
3184        );
3185
3186        // The verify path reconstructs the same canonical bytes from its owned
3187        // form before checking the signature.
3188        let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
3189        let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
3190        let verified_canonical = ReceiptPayload {
3191            kind: &verified.kind,
3192            reference: &verified.reference,
3193            amount: &verified.amount,
3194            currency: &verified.currency,
3195            recipient: &verified.recipient,
3196            method: &verified.method,
3197            timestamp: &verified.timestamp,
3198            tool_call_id: &verified.tool_call_id,
3199            approval_pos: &verified.approval_pos,
3200            approved_args_hash: &verified.approved_args_hash,
3201            subject: &verified.subject,
3202        }
3203        .canonical_json()
3204        .to_string()
3205        .into_bytes();
3206        assert_eq!(
3207            verified_canonical, canonical_bytes,
3208            "verify path must derive canonical JSON from the same single source"
3209        );
3210    }
3211
3212    #[test]
3213    fn crypto_receipt_payload_signs_and_verifies() {
3214        let signer = ApprovalSigner::from_seed(99);
3215        let trusted = vec![signer.public_key_bytes()];
3216        let (payload, _sig, _pk) = receipt_payload(
3217            &ReceiptPayload {
3218                kind: "outbound_payment_receipt",
3219                reference: "tx-abc",
3220                amount: "0.01",
3221                currency: "USDC",
3222                recipient: "0xrecipient",
3223                method: "tempo",
3224                timestamp: "2026-06-02T00:00:00Z",
3225                tool_call_id: "call-1",
3226                approval_pos: "42",
3227                approved_args_hash: "abcd1234",
3228                subject: "conv-xyz",
3229            },
3230            &signer,
3231        );
3232        let verified = verify_signed_receipt(&payload, &trusted)
3233            .expect("signature verifies on untampered receipt");
3234        assert_eq!(verified.reference, "tx-abc");
3235        assert_eq!(verified.amount, "0.01");
3236        assert_eq!(verified.currency, "USDC");
3237        assert_eq!(verified.recipient, "0xrecipient");
3238        assert_eq!(verified.method, "tempo");
3239        assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
3240        // The v2 kind + binding tuple + opaque subject round-trip and are
3241        // covered by the signature.
3242        assert_eq!(verified.version, RECEIPT_VERSION);
3243        assert_eq!(verified.kind, "outbound_payment_receipt");
3244        assert_eq!(verified.tool_call_id, "call-1");
3245        assert_eq!(verified.approval_pos, "42");
3246        assert_eq!(verified.approved_args_hash, "abcd1234");
3247        assert_eq!(verified.subject, "conv-xyz");
3248    }
3249
3250    #[test]
3251    fn tampered_receipt_fails_verification() {
3252        let signer = ApprovalSigner::from_seed(99);
3253        let trusted = vec![signer.public_key_bytes()];
3254        let (payload, _sig, _pk) = receipt_payload(
3255            &ReceiptPayload {
3256                kind: "outbound_payment_receipt",
3257                reference: "tx-abc",
3258                amount: "0.01",
3259                currency: "USDC",
3260                recipient: "0xrecipient",
3261                method: "tempo",
3262                timestamp: "2026-06-02T00:00:00Z",
3263                tool_call_id: "call-1",
3264                approval_pos: "42",
3265                approved_args_hash: "abcd1234",
3266                subject: "conv-xyz",
3267            },
3268            &signer,
3269        );
3270        // Tamper with a covered field (amount).
3271        let mut v: Value = serde_json::from_slice(&payload).unwrap();
3272        v["amount"] = Value::String("9999.00".to_owned());
3273        let tampered = v.to_string().into_bytes();
3274        assert!(verify_signed_receipt(&tampered, &trusted).is_none());
3275    }
3276
3277    #[test]
3278    fn tampered_receipt_binding_field_fails_verification() {
3279        let signer = ApprovalSigner::from_seed(99);
3280        let trusted = vec![signer.public_key_bytes()];
3281        let (payload, _sig, _pk) = receipt_payload(
3282            &ReceiptPayload {
3283                kind: "outbound_payment_receipt",
3284                reference: "tx-abc",
3285                amount: "0.01",
3286                currency: "USDC",
3287                recipient: "0xrecipient",
3288                method: "tempo",
3289                timestamp: "2026-06-02T00:00:00Z",
3290                tool_call_id: "call-1",
3291                approval_pos: "42",
3292                approved_args_hash: "abcd1234",
3293                subject: "conv-xyz",
3294            },
3295            &signer,
3296        );
3297        // Re-pointing the receipt at a different approval position invalidates
3298        // the signature — the binding tuple is covered, not advisory.
3299        let mut v: Value = serde_json::from_slice(&payload).unwrap();
3300        v["approval_pos"] = Value::String("7".to_owned());
3301        let tampered = v.to_string().into_bytes();
3302        assert!(verify_signed_receipt(&tampered, &trusted).is_none());
3303
3304        // Re-filing the payload under the other direction's kind likewise
3305        // fails — the signed `kind` is what makes direction trustworthy
3306        // independent of the (unsigned) stored event kind.
3307        let mut v: Value = serde_json::from_slice(&payload).unwrap();
3308        v["kind"] = Value::String("payment_receipt".to_owned());
3309        let refiled = v.to_string().into_bytes();
3310        assert!(verify_signed_receipt(&refiled, &trusted).is_none());
3311    }
3312
3313    /// The acceptance gate (issue #806): a receipt signed by a key that is
3314    /// NOT on the trusted-signer allow-list must fail verification, even
3315    /// though its signature is perfectly self-consistent — proving the
3316    /// allow-list, not mere signature validity, gates trust. The SAME
3317    /// receipt verifies once its signer is added to the allow-list.
3318    #[test]
3319    fn receipt_from_non_allowlisted_signer_is_rejected() {
3320        let trusted_signer = ApprovalSigner::from_seed(99);
3321        let attacker_signer = ApprovalSigner::from_seed(31337);
3322        let fields = ReceiptPayload {
3323            kind: "outbound_payment_receipt",
3324            reference: "tx-forged",
3325            amount: "100.00",
3326            currency: "USDC",
3327            recipient: "0xattacker",
3328            method: "tempo",
3329            timestamp: "2026-06-02T00:00:00Z",
3330            tool_call_id: "call-1",
3331            approval_pos: "42",
3332            approved_args_hash: "abcd1234",
3333            subject: "conv-xyz",
3334        };
3335        // The attacker signs a fully self-consistent receipt with THEIR OWN
3336        // key (not a forged signature over the trusted signer's key).
3337        let (payload, _sig, _pk) = receipt_payload(&fields, &attacker_signer);
3338
3339        // An allow-list that does not include the attacker's key rejects it,
3340        // no matter how internally consistent the signature is.
3341        let trusted = vec![trusted_signer.public_key_bytes()];
3342        assert!(
3343            verify_signed_receipt(&payload, &trusted).is_none(),
3344            "a receipt signed by a non-allow-listed key must not verify"
3345        );
3346
3347        // The IDENTICAL payload verifies once the attacker's key is
3348        // allow-listed (proves the rejection above was the allow-list, not
3349        // some other defect).
3350        let trusted_plus_attacker = vec![
3351            trusted_signer.public_key_bytes(),
3352            attacker_signer.public_key_bytes(),
3353        ];
3354        assert!(
3355            verify_signed_receipt(&payload, &trusted_plus_attacker).is_some(),
3356            "the same receipt must verify once its signer is allow-listed"
3357        );
3358
3359        // An empty allow-list rejects every signer, including the deployment's
3360        // own — fail closed, never fail open on a misconfigured (empty) list.
3361        assert!(verify_signed_receipt(&payload, &[]).is_none());
3362    }
3363
3364    /// The acceptance gate (issue #845): a `grant_replay` audit record signed by
3365    /// a key that is NOT on the trusted-signer allow-list must fail
3366    /// verification, even though its signature is perfectly self-consistent —
3367    /// the allow-list, not mere signature validity, gates trust. The SAME record
3368    /// verifies once its signer is allow-listed. Mirrors
3369    /// [`receipt_from_non_allowlisted_signer_is_rejected`].
3370    #[test]
3371    fn grant_replay_from_non_allowlisted_signer_is_rejected() {
3372        let trusted_signer = ApprovalSigner::from_seed(99);
3373        let attacker_signer = ApprovalSigner::from_seed(31337);
3374        let covered = vec!["arbitrary-egress".to_owned()];
3375        // The attacker signs a fully self-consistent record with THEIR OWN key.
3376        let (payload, _sig, _pk) = grant_replay_payload(
3377            "conv-1",
3378            "turn-7",
3379            "post_summary",
3380            "deadbeef",
3381            &covered,
3382            "sha256:template-abc",
3383            &attacker_signer,
3384        );
3385
3386        // The unpinned verifier accepts it (signature is internally consistent) —
3387        // exactly the forgery surface this issue closes.
3388        assert!(
3389            verify_grant_replay(&payload),
3390            "the unpinned verifier trusts any self-consistent signature"
3391        );
3392
3393        // An allow-list that does not include the attacker's key rejects it.
3394        let trusted = vec![trusted_signer.public_key_bytes()];
3395        assert!(
3396            !verify_grant_replay_pinned(&payload, &trusted),
3397            "a grant_replay signed by a non-allow-listed key must not verify"
3398        );
3399
3400        // The IDENTICAL payload verifies once the attacker's key is allow-listed
3401        // (proves the rejection was the allow-list, not some other defect).
3402        let trusted_plus_attacker = vec![
3403            trusted_signer.public_key_bytes(),
3404            attacker_signer.public_key_bytes(),
3405        ];
3406        assert!(
3407            verify_grant_replay_pinned(&payload, &trusted_plus_attacker),
3408            "the same record must verify once its signer is allow-listed"
3409        );
3410
3411        // An empty allow-list rejects every signer — fail closed.
3412        assert!(!verify_grant_replay_pinned(&payload, &[]));
3413    }
3414
3415    /// The acceptance gate (issue #845): an `approval_response` signed by a key
3416    /// that is NOT on the trusted-signer allow-list must fail verification via
3417    /// both [`verify_signed_response_pinned`] and the single-use
3418    /// [`verify_capability`] gate, even though its signature is self-consistent.
3419    /// The SAME response verifies once its signer is allow-listed. Mirrors
3420    /// [`receipt_from_non_allowlisted_signer_is_rejected`].
3421    #[test]
3422    fn signed_response_from_non_allowlisted_signer_is_rejected() {
3423        let trusted_signer = ApprovalSigner::from_seed(99);
3424        let attacker_signer = ApprovalSigner::from_seed(31337);
3425        // The attacker self-signs a fully consistent approval with their key.
3426        let (payload, _sig, _pk) = response_payload(
3427            "call-0",
3428            "delete_file",
3429            r#"{"path":"/etc/hosts"}"#,
3430            "",
3431            true,
3432            false,
3433            &[],
3434            "slack:T1:U9",
3435            "slack:T1:U9",
3436            "workspace-write",
3437            "ok",
3438            "",
3439            "conv-A",
3440            "nonce-A",
3441            &attacker_signer,
3442        );
3443
3444        // The unpinned verifier accepts it — the forgery surface this closes.
3445        assert!(
3446            verify_signed_response(&payload).is_some(),
3447            "the unpinned verifier trusts any self-consistent signature"
3448        );
3449
3450        // Pinned to a legit allow-list that excludes the attacker ⇒ rejected,
3451        // both as a bare response and through the capability consume gate.
3452        let trusted = vec![trusted_signer.public_key_bytes()];
3453        let consumed = HashSet::new();
3454        assert!(
3455            verify_signed_response_pinned(&payload, &trusted).is_none(),
3456            "an approval_response signed by a non-allow-listed key must not verify"
3457        );
3458        assert!(
3459            verify_capability(&payload, "conv-A", &consumed, &trusted).is_none(),
3460            "the capability gate must reject a non-allow-listed signer"
3461        );
3462
3463        // The IDENTICAL payload verifies once the attacker's key is allow-listed.
3464        let trusted_plus_attacker = vec![
3465            trusted_signer.public_key_bytes(),
3466            attacker_signer.public_key_bytes(),
3467        ];
3468        assert!(
3469            verify_signed_response_pinned(&payload, &trusted_plus_attacker).is_some(),
3470            "the same response must verify once its signer is allow-listed"
3471        );
3472        assert!(
3473            verify_capability(&payload, "conv-A", &consumed, &trusted_plus_attacker).is_some(),
3474            "the capability gate honors an allow-listed signer"
3475        );
3476
3477        // An empty allow-list rejects every signer — fail closed.
3478        assert!(verify_signed_response_pinned(&payload, &[]).is_none());
3479        assert!(verify_capability(&payload, "conv-A", &consumed, &[]).is_none());
3480    }
3481
3482    /// A receipt persisted before the v2 binding (no `version`, six fields only)
3483    /// must still verify for forensics, surfacing as `version == 1` with empty
3484    /// binding fields. Mirrors the legacy approval-response path.
3485    #[test]
3486    fn legacy_v1_receipt_still_verifies() {
3487        let signer = ApprovalSigner::from_seed(99);
3488        let trusted = vec![signer.public_key_bytes()];
3489        let canonical = serde_json::json!({
3490            "reference": "tx-old",
3491            "amount": "0.02",
3492            "currency": "USDC",
3493            "recipient": "0xr",
3494            "method": "tempo",
3495            "timestamp": "2026-06-01T00:00:00Z",
3496        });
3497        let sig = signer.sign(canonical.to_string().as_bytes());
3498        let pk = signer.public_key_bytes();
3499        let v1 = serde_json::json!({
3500            "reference": "tx-old",
3501            "amount": "0.02",
3502            "currency": "USDC",
3503            "recipient": "0xr",
3504            "method": "tempo",
3505            "timestamp": "2026-06-01T00:00:00Z",
3506            "signed_by": crate::hex::lower(&pk),
3507            "signature_hex": crate::hex::lower(&sig),
3508        })
3509        .to_string()
3510        .into_bytes();
3511
3512        let verified =
3513            verify_signed_receipt(&v1, &trusted).expect("a valid v1 receipt still verifies");
3514        assert_eq!(verified.version, 1);
3515        assert_eq!(verified.reference, "tx-old");
3516        assert!(verified.kind.is_empty());
3517        assert!(verified.tool_call_id.is_empty());
3518        assert!(verified.approval_pos.is_empty());
3519        assert!(verified.subject.is_empty());
3520    }
3521
3522    /// Injecting a `version` key into a validly-signed legacy receipt must not
3523    /// verify: the v1 canonical does not cover `version`, so dispatching the
3524    /// claimed version to the v1 canonical would let the signature check pass
3525    /// while `VerifiedReceipt.version` echoed an unsigned, writer-chosen value.
3526    /// Only an absent `version` (⇒ 1) or the exact current version is accepted.
3527    #[test]
3528    fn injected_version_on_v1_signed_receipt_fails() {
3529        let signer = ApprovalSigner::from_seed(99);
3530        let trusted = vec![signer.public_key_bytes()];
3531        let canonical = serde_json::json!({
3532            "reference": "tx-old",
3533            "amount": "0.02",
3534            "currency": "USDC",
3535            "recipient": "0xr",
3536            "method": "tempo",
3537            "timestamp": "2026-06-01T00:00:00Z",
3538        });
3539        let sig = signer.sign(canonical.to_string().as_bytes());
3540        let pk = signer.public_key_bytes();
3541        let mut full = canonical;
3542        full["signed_by"] = Value::String(crate::hex::lower(&pk));
3543        full["signature_hex"] = Value::String(crate::hex::lower(&sig));
3544
3545        // A claimed future version, an explicit "1", and a non-integer are all
3546        // refused outright (fail closed) — never verified against a guessed
3547        // canonical.
3548        for injected in [
3549            Value::from(7_u64),
3550            Value::from(1_u64),
3551            Value::String("2".to_owned()),
3552        ] {
3553            let mut tampered = full.clone();
3554            tampered["version"] = injected;
3555            assert!(
3556                verify_signed_receipt(&tampered.to_string().into_bytes(), &trusted).is_none(),
3557                "a writer-chosen version key must never verify"
3558            );
3559        }
3560        // Sanity: without the injected key the same payload verifies as v1.
3561        assert!(verify_signed_receipt(&full.to_string().into_bytes(), &trusted).is_some());
3562    }
3563}
3564
3565#[cfg(test)]
3566mod resolve_token_tests {
3567    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
3568    use super::*;
3569
3570    #[test]
3571    fn resolve_token_verifies_for_its_own_request_and_conversation() {
3572        let signer = ApprovalSigner::from_seed(11);
3573        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
3574        assert!(verify_resolve_token(
3575            &token, "call-1", "conv-a", 1_000, &signer
3576        ));
3577    }
3578
3579    #[test]
3580    fn resolve_token_rejects_a_different_request_id() {
3581        let signer = ApprovalSigner::from_seed(11);
3582        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
3583        assert!(!verify_resolve_token(
3584            &token, "call-2", "conv-a", 1_000, &signer
3585        ));
3586    }
3587
3588    #[test]
3589    fn resolve_token_rejects_a_different_conversation() {
3590        let signer = ApprovalSigner::from_seed(11);
3591        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
3592        assert!(!verify_resolve_token(
3593            &token, "call-1", "conv-b", 1_000, &signer
3594        ));
3595    }
3596
3597    #[test]
3598    fn resolve_token_rejects_wrong_signer() {
3599        let signer = ApprovalSigner::from_seed(11);
3600        let other = ApprovalSigner::from_seed(12);
3601        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
3602        assert!(!verify_resolve_token(
3603            &token, "call-1", "conv-a", 1_000, &other
3604        ));
3605    }
3606
3607    #[test]
3608    fn resolve_token_rejects_after_ttl_elapses() {
3609        let signer = ApprovalSigner::from_seed(11);
3610        let token = mint_resolve_token("call-1", "conv-a", 0, &signer);
3611        assert!(verify_resolve_token(
3612            &token,
3613            "call-1",
3614            "conv-a",
3615            RESOLVE_TOKEN_TTL_MS,
3616            &signer
3617        ));
3618        assert!(!verify_resolve_token(
3619            &token,
3620            "call-1",
3621            "conv-a",
3622            RESOLVE_TOKEN_TTL_MS + 1,
3623            &signer
3624        ));
3625    }
3626
3627    #[test]
3628    fn resolve_token_rejects_garbage() {
3629        let signer = ApprovalSigner::from_seed(11);
3630        assert!(!verify_resolve_token(
3631            "not-hex", "call-1", "conv-a", 0, &signer
3632        ));
3633        assert!(!verify_resolve_token("", "call-1", "conv-a", 0, &signer));
3634    }
3635}
3636
3637#[cfg(test)]
3638mod admin_model_change_tests {
3639    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
3640    use super::*;
3641
3642    #[test]
3643    fn admin_model_change_round_trips_and_is_tamper_evident() {
3644        let signer = ApprovalSigner::from_seed(31);
3645        let (payload, _sig, _pk) = admin_model_change_payload(
3646            "team-a",
3647            "vertex",
3648            "old-model",
3649            "vertex",
3650            "new-model",
3651            1_000,
3652            &signer,
3653        );
3654        let verified = verify_admin_model_change(&payload).expect("genuine record verifies");
3655        assert_eq!(verified.principal, "team-a");
3656        assert_eq!(verified.new_model, "new-model");
3657        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
3658
3659        for (field, val) in [
3660            ("principal", serde_json::json!("attacker")),
3661            ("new_model", serde_json::json!("evil-model")),
3662            ("new_provider", serde_json::json!("evil-provider")),
3663        ] {
3664            let mut v: Value = serde_json::from_slice(&payload).unwrap();
3665            v[field] = val;
3666            assert!(
3667                verify_admin_model_change(v.to_string().as_bytes()).is_none(),
3668                "tampered {field} must fail verification"
3669            );
3670        }
3671    }
3672}