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: ¤cy,
1449 recipient: &recipient,
1450 method: &method,
1451 timestamp: ×tamp,
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}