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