use std::collections::HashSet;
use std::sync::Arc;
use serde_json::Value;
use crate::{Signer, verify};
#[derive(Clone)]
pub struct ApprovalSigner {
inner: Arc<Signer>,
}
impl Default for ApprovalSigner {
fn default() -> Self {
Self::from_seed(1)
}
}
impl ApprovalSigner {
#[must_use]
pub fn from_seed(seed: u64) -> Self {
Self {
inner: Arc::new(Signer::from_seed(seed)),
}
}
#[must_use]
pub fn public_key_bytes(&self) -> Vec<u8> {
self.inner.public_key_bytes()
}
#[must_use]
pub fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
self.inner.sign(canonical_bytes)
}
}
#[must_use]
pub fn request_payload(
request_id: &str,
tool_name: &str,
args_json: &str,
sandbox_mode: &str,
reason: &str,
missing_capabilities: &[String],
) -> Vec<u8> {
serde_json::json!({
"tool_name": tool_name,
"args_json": args_json,
"request_id": request_id,
"sandbox_mode": sandbox_mode,
"reason": reason,
"missing_capabilities": missing_capabilities,
})
.to_string()
.into_bytes()
}
#[allow(clippy::too_many_arguments)] fn response_canonical(
request_id: &str,
tool_name: &str,
args_json: &str,
modified_args_json: &str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &[String],
caller: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
) -> Vec<u8> {
serde_json::json!({
"request_id": request_id,
"tool_name": tool_name,
"args_json": args_json,
"modified_args_json": modified_args_json,
"approved": approved,
"approved_for_session": approved_for_session,
"covered_capabilities": covered_capabilities,
"caller": caller,
"sandbox_mode": sandbox_mode,
"reason": reason,
"injected_context": injected_context,
"conversation_id": conversation_id,
"nonce": nonce,
})
.to_string()
.into_bytes()
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub fn response_payload(
request_id: &str,
tool_name: &str,
args_json: &str,
modified_args_json: &str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &[String],
caller: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical_bytes = response_canonical(
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
);
let signature = signer.sign(&canonical_bytes);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"request_id": request_id,
"tool_name": tool_name,
"args_json": args_json,
"modified_args_json": modified_args_json,
"approved": approved,
"approved_for_session": approved_for_session,
"covered_capabilities": covered_capabilities,
"caller": caller,
"sandbox_mode": sandbox_mode,
"reason": reason,
"injected_context": injected_context,
"conversation_id": conversation_id,
"nonce": nonce,
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
fn excision_canonical(
conversation_id: &str,
scope: &str,
positions: &[u64],
requested_by: &str,
reason: &str,
) -> Vec<u8> {
serde_json::json!({
"conversation_id": conversation_id,
"scope": scope,
"positions": positions,
"requested_by": requested_by,
"reason": reason,
})
.to_string()
.into_bytes()
}
#[must_use]
pub fn excision_payload(
conversation_id: &str,
scope: &str,
positions: &[u64],
requested_by: &str,
reason: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical = excision_canonical(conversation_id, scope, positions, requested_by, reason);
let signature = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"conversation_id": conversation_id,
"scope": scope,
"positions": positions,
"requested_by": requested_by,
"reason": reason,
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedExcision {
pub conversation_id: String,
pub scope: String,
pub positions: Vec<u64>,
pub requested_by: String,
pub reason: String,
pub signer_public_key: Vec<u8>,
}
impl VerifiedExcision {
#[must_use]
pub fn is_cascade(&self) -> bool {
self.scope == EXCISION_SCOPE_CASCADE
}
}
#[must_use]
pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
let v: Value = serde_json::from_slice(payload).ok()?;
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let scope = v.get("scope")?.as_str()?.to_owned();
if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
return None;
}
let positions: Vec<u64> = v
.get("positions")?
.as_array()?
.iter()
.map(serde_json::Value::as_u64)
.collect::<Option<Vec<_>>>()?;
let requested_by = v.get("requested_by")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
let canonical =
excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
if verify(&pk, &canonical, &sig) {
Some(VerifiedExcision {
conversation_id,
scope,
positions,
requested_by,
reason,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn deferred_payload(
request_id: &str,
conversation_id: &str,
reason: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical = serde_json::json!({
"request_id": request_id,
"conversation_id": conversation_id,
"reason": reason,
})
.to_string()
.into_bytes();
let signature = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"request_id": request_id,
"conversation_id": conversation_id,
"reason": reason,
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
#[must_use]
pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
let canonical = serde_json::json!({
"request_id": request_id,
"conversation_id": conversation_id,
"reason": reason,
})
.to_string()
.into_bytes();
verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub fn mutation_payload(
kind: &str,
tool_call_id: &str,
tool_name: &str,
conversation_id: &str,
before: &str,
after: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical = serde_json::json!({
"kind": kind,
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"conversation_id": conversation_id,
"before": before,
"after": after,
})
.to_string()
.into_bytes();
let signature = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"kind": kind,
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"conversation_id": conversation_id,
"before": before,
"after": after,
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
#[must_use]
pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let kind = v.get("kind")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let tool_name = v.get("tool_name")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let before = v.get("before")?.as_str()?.to_owned();
let after = v.get("after")?.as_str()?.to_owned();
let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
let canonical = serde_json::json!({
"kind": kind,
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"conversation_id": conversation_id,
"before": before,
"after": after,
})
.to_string()
.into_bytes();
verify(&pk, &canonical, &sig).then_some((
kind,
tool_call_id,
tool_name,
conversation_id,
before,
after,
))
}
pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
#[must_use]
pub fn auto_review_reason(tier: &str) -> String {
format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
}
#[must_use]
pub fn is_auto_review_reason(reason: &str) -> bool {
reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
}
pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub request_id: String,
pub tool_name: String,
pub args_json: String,
pub modified_args_json: String,
pub approved: bool,
pub approved_for_session: bool,
pub covered_capabilities: Vec<String>,
pub caller: String,
pub sandbox_mode: String,
pub reason: String,
pub injected_context: String,
pub conversation_id: String,
pub nonce: String,
pub signer_public_key: Vec<u8>,
}
impl VerifiedResponse {
#[must_use]
pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
self.approved
&& self.request_id == request_id
&& self.tool_name == tool_name
&& self.args_json == args_json
}
}
#[must_use]
pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let tool_name = v.get("tool_name")?.as_str()?.to_owned();
let args_json = v.get("args_json")?.as_str()?.to_owned();
let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
let approved = v.get("approved")?.as_bool()?;
let approved_for_session = v.get("approved_for_session")?.as_bool()?;
let covered_capabilities: Vec<String> = v
.get("covered_capabilities")?
.as_array()?
.iter()
.map(|c| c.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()?;
let caller = v.get("caller")?.as_str()?.to_owned();
let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let injected_context = v.get("injected_context")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let nonce = v.get("nonce")?.as_str()?.to_owned();
let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
let canonical = response_canonical(
&request_id,
&tool_name,
&args_json,
&modified_args_json,
approved,
approved_for_session,
&covered_capabilities,
&caller,
&sandbox_mode,
&reason,
&injected_context,
&conversation_id,
&nonce,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedResponse {
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn verify_capability<S: std::hash::BuildHasher>(
payload: &[u8],
conversation_id: &str,
consumed: &HashSet<String, S>,
) -> Option<VerifiedResponse> {
let verified = verify_signed_response(payload)?;
if verified.conversation_id != conversation_id {
return None;
}
if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
return None;
}
Some(verified)
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub fn verify_wire_response(
request_id: &str,
tool_name: &str,
args_json: &str,
modified_args_json: &str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &[String],
caller: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
signer_pk_hex: &str,
signature_hex: &str,
) -> bool {
let Some(pk) = hex_decode(signer_pk_hex) else {
return false;
};
let Some(sig) = hex_decode(signature_hex) else {
return false;
};
let canonical = response_canonical(
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
);
verify(&pk, &canonical, &sig)
}
#[must_use]
pub fn is_session_grant_for(
approved: bool,
approved_for_session: bool,
caller: &str,
current_caller: &str,
) -> bool {
approved && approved_for_session && !caller.is_empty() && caller == current_caller
}
#[must_use]
pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let approved = v.get("approved")?.as_bool()?;
Some((request_id, approved))
}
#[derive(Debug, Clone)]
pub struct DecodedResponse {
pub request_id: String,
pub tool_name: String,
pub args_json: String,
pub modified_args_json: String,
pub approved: bool,
pub approved_for_session: bool,
pub covered_capabilities: Vec<String>,
pub caller: String,
pub sandbox_mode: String,
pub reason: String,
pub injected_context: String,
pub conversation_id: String,
pub nonce: String,
pub signer_pk_hex: String,
pub signature_hex: String,
}
#[must_use]
pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some(DecodedResponse {
request_id: v.get("request_id")?.as_str()?.to_owned(),
tool_name: v.get("tool_name")?.as_str()?.to_owned(),
args_json: v.get("args_json")?.as_str()?.to_owned(),
modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
approved: v.get("approved")?.as_bool()?,
approved_for_session: v.get("approved_for_session")?.as_bool()?,
covered_capabilities: v
.get("covered_capabilities")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|c| c.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default(),
caller: v.get("caller")?.as_str()?.to_owned(),
sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
reason: v.get("reason")?.as_str()?.to_owned(),
injected_context: v.get("injected_context")?.as_str()?.to_owned(),
conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
nonce: v.get("nonce")?.as_str()?.to_owned(),
signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
})
}
pub const RECEIPT_VERSION: u64 = 2;
#[derive(Debug, Clone, Copy)]
pub struct ReceiptPayload<'a> {
pub kind: &'a str,
pub reference: &'a str,
pub amount: &'a str,
pub currency: &'a str,
pub recipient: &'a str,
pub method: &'a str,
pub timestamp: &'a str,
pub tool_call_id: &'a str,
pub approval_pos: &'a str,
pub approved_args_hash: &'a str,
pub subject: &'a str,
}
impl ReceiptPayload<'_> {
#[must_use]
fn canonical_json(&self) -> Value {
serde_json::json!({
"version": RECEIPT_VERSION,
"kind": self.kind,
"reference": self.reference,
"amount": self.amount,
"currency": self.currency,
"recipient": self.recipient,
"method": self.method,
"timestamp": self.timestamp,
"tool_call_id": self.tool_call_id,
"approval_pos": self.approval_pos,
"approved_args_hash": self.approved_args_hash,
"subject": self.subject,
})
}
#[must_use]
fn canonical_json_v1(&self) -> Value {
serde_json::json!({
"reference": self.reference,
"amount": self.amount,
"currency": self.currency,
"recipient": self.recipient,
"method": self.method,
"timestamp": self.timestamp,
})
}
}
#[must_use]
pub fn receipt_payload(
fields: &ReceiptPayload<'_>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let mut full = fields.canonical_json();
let canonical_bytes = full.to_string().into_bytes();
let signature = signer.sign(&canonical_bytes);
let pk = signer.public_key_bytes();
if let Value::Object(map) = &mut full {
map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
map.insert(
"signature_hex".to_owned(),
Value::String(hex_lower(&signature)),
);
}
(full.to_string().into_bytes(), signature, pk)
}
#[derive(Debug, Clone)]
pub struct VerifiedReceipt {
pub reference: String,
pub amount: String,
pub currency: String,
pub recipient: String,
pub method: String,
pub timestamp: String,
pub version: u64,
pub kind: String,
pub tool_call_id: String,
pub approval_pos: String,
pub approved_args_hash: String,
pub subject: String,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn verify_signed_receipt(payload: &[u8]) -> Option<VerifiedReceipt> {
let v: Value = serde_json::from_slice(payload).ok()?;
let reference = v.get("reference")?.as_str()?.to_owned();
let amount = v.get("amount")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let recipient = v.get("recipient")?.as_str()?.to_owned();
let method = v.get("method")?.as_str()?.to_owned();
let timestamp = v.get("timestamp")?.as_str()?.to_owned();
let signed_by_hex = v.get("signed_by")?.as_str()?;
let signature_hex = v.get("signature_hex")?.as_str()?;
let pk = hex_decode(signed_by_hex)?;
let sig = hex_decode(signature_hex)?;
let version = match v.get("version") {
None => 1,
Some(n) if n.as_u64() == Some(RECEIPT_VERSION) => RECEIPT_VERSION,
Some(_) => return None,
};
let (kind, tool_call_id, approval_pos, approved_args_hash, subject) =
if version == RECEIPT_VERSION {
(
v.get("kind")?.as_str()?.to_owned(),
v.get("tool_call_id")?.as_str()?.to_owned(),
v.get("approval_pos")?.as_str()?.to_owned(),
v.get("approved_args_hash")?.as_str()?.to_owned(),
v.get("subject")?.as_str()?.to_owned(),
)
} else {
(
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
)
};
let fields = ReceiptPayload {
kind: &kind,
reference: &reference,
amount: &amount,
currency: ¤cy,
recipient: &recipient,
method: &method,
timestamp: ×tamp,
tool_call_id: &tool_call_id,
approval_pos: &approval_pos,
approved_args_hash: &approved_args_hash,
subject: &subject,
};
let canonical_bytes = if version == RECEIPT_VERSION {
fields.canonical_json()
} else {
fields.canonical_json_v1()
}
.to_string()
.into_bytes();
if verify(&pk, &canonical_bytes, &sig) {
Some(VerifiedReceipt {
reference,
amount,
currency,
recipient,
method,
timestamp,
version,
kind,
tool_call_id,
approval_pos,
approved_args_hash,
subject,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn decode_request_id(payload: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some(v.get("request_id")?.as_str()?.to_owned())
}
#[must_use]
pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some((
v.get("request_id")?.as_str()?.to_owned(),
v.get("tool_name")?.as_str()?.to_owned(),
v.get("args_json")?.as_str()?.to_owned(),
))
}
#[must_use]
pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
serde_json::from_slice::<Value>(payload)
.ok()
.and_then(|v| {
v.get("sandbox_mode")
.and_then(Value::as_str)
.map(str::to_owned)
})
.unwrap_or_default()
}
#[must_use]
pub fn decode_request_reason(payload: &[u8]) -> String {
serde_json::from_slice::<Value>(payload)
.ok()
.and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
.unwrap_or_default()
}
#[must_use]
pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
serde_json::from_slice::<Value>(payload)
.ok()
.and_then(|v| {
v.get("missing_capabilities")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|c| c.as_str().map(str::to_owned))
.collect()
})
})
.unwrap_or_default()
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(&mut s, "{b:02x}");
}
s
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn covered_capabilities_are_signed_and_tamper_evident() {
let signer = ApprovalSigner::from_seed(42);
let covered = vec!["arbitrary-egress".to_owned()];
let (payload, _sig, _pk) = response_payload(
"req-1",
"web_fetch",
r#"{"url":"https://a"}"#,
"",
true,
true,
&covered,
"slack:T1:U9",
"workspace-write",
"",
"",
"conv-1",
"nonce-1",
&signer,
);
let verified = verify_signed_response(&payload).expect("verifies untampered");
assert_eq!(verified.covered_capabilities, covered);
let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
assert!(
verify_signed_response(v.to_string().as_bytes()).is_none(),
"a tampered covered set must fail verification"
);
let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
v["covered_capabilities"] = serde_json::json!([]);
assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
}
#[test]
fn request_missing_capabilities_round_trip() {
let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing);
assert_eq!(decode_request_missing_capabilities(&bytes), missing);
let bare = request_payload("call-2", "grep", "{}", "", "", &[]);
assert_eq!(
decode_request_missing_capabilities(&bare),
Vec::<String>::new()
);
assert_eq!(
decode_request_missing_capabilities(b"{\"nope\":1}"),
Vec::<String>::new()
);
}
#[test]
fn signed_excision_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = excision_payload(
"conv-1",
EXCISION_SCOPE_CASCADE,
&[17, 23],
"persona-9",
"poisoned fetch",
&signer,
);
let v = verify_signed_excision(&payload).expect("verifies untampered");
assert_eq!(v.conversation_id, "conv-1");
assert!(v.is_cascade());
assert_eq!(v.positions, vec![17, 23]);
assert_eq!(v.requested_by, "persona-9");
for (field, value) in [
("positions", serde_json::json!([17, 23, 40])),
("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
("conversation_id", serde_json::json!("conv-2")),
("requested_by", serde_json::json!("someone-else")),
] {
let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
t[field] = value;
assert!(
verify_signed_excision(t.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
t["scope"] = serde_json::json!("everything");
assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
assert!(verify_signed_excision(b"not json").is_none());
}
#[test]
fn signed_response_round_trips() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) = response_payload(
"req-1",
"rm",
r#"{"path":"/etc"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"looks fine",
"",
"conv-1",
"nonce-1",
&signer,
);
let verified =
verify_signed_response(&payload).expect("signature verifies on untampered payload");
assert!(verified.approved);
assert_eq!(verified.reason, "looks fine");
assert_eq!(verified.request_id, "req-1");
assert_eq!(verified.tool_name, "rm");
assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
assert_eq!(verified.caller, "slack:T1:U9");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.nonce, "nonce-1");
assert!(verified.approved);
assert!(!verified.approved_for_session);
}
#[test]
fn session_response_round_trips_with_caller_binding() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) = response_payload(
"req-1",
"grep",
r#"{"pattern":"x"}"#,
"",
true,
true,
&[],
"slack:T1:U9",
"workspace-write",
"remember it",
"",
"conv-1",
"nonce-1",
&signer,
);
let verified = verify_signed_response(&payload).expect("session signature verifies");
assert!(verified.approved);
assert!(verified.approved_for_session, "carries session scope");
assert_eq!(verified.caller, "slack:T1:U9");
assert_eq!(verified.tool_name, "grep");
assert!(verified.approved);
}
#[test]
fn tampered_session_or_caller_fails_verification() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) = response_payload(
"req-1",
"grep",
"{}",
"",
true,
true,
&[],
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-1",
"nonce-1",
&signer,
);
for (field, val) in [
("caller", Value::String("slack:T1:ATTACKER".to_owned())),
("approved_for_session", Value::Bool(false)),
("tool_name", Value::String("rm".to_owned())),
("approved", Value::Bool(false)),
("args_json", Value::String("EVIL".to_owned())),
(
"modified_args_json",
Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
),
("injected_context", Value::String("do EVIL".to_owned())),
(
"sandbox_mode",
Value::String("danger-full-access".to_owned()),
),
("conversation_id", Value::String("conv-OTHER".to_owned())),
("nonce", Value::String("nonce-OTHER".to_owned())),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_signed_response(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail verification"
);
}
}
#[test]
fn edited_response_binds_proposed_and_carries_modified() {
let signer = ApprovalSigner::from_seed(7);
let proposed = r#"{"path":"/etc/shadow"}"#;
let edited = r#"{"path":"/etc/hostname"}"#;
let (payload, _sig, _pk) = response_payload(
"call-1",
"read_file",
proposed,
edited,
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"narrowed the path",
"",
"conv-1",
"nonce-1",
&signer,
);
let v = verify_signed_response(&payload).expect("edited approval verifies");
assert_eq!(v.args_json, proposed, "identity binds the proposed args");
assert_eq!(
v.modified_args_json, edited,
"the edit is carried and signed"
);
assert!(
v.authorizes_call("call-1", "read_file", proposed),
"the exact proposed call is authorized"
);
assert!(
!v.authorizes_call("call-1", "read_file", edited),
"the edited args are NOT the identity — authorizes_call binds proposed"
);
}
#[test]
fn unedited_response_carries_empty_edit() {
let signer = ApprovalSigner::from_seed(7);
let proposed = r#"{"path":"/tmp/x"}"#;
let (payload, _sig, _pk) = response_payload(
"call-1",
"read_file",
proposed,
"",
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-1",
"nonce-1",
&signer,
);
let v = verify_signed_response(&payload).expect("verifies");
assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
assert!(v.authorizes_call("call-1", "read_file", proposed));
}
#[test]
fn injected_context_round_trips_and_is_signed() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = response_payload(
"call-1",
"shell",
r#"{"cmd":"ls"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"ok",
"only touch files under src/",
"conv-1",
"nonce-1",
&signer,
);
let v = verify_signed_response(&payload).expect("verifies");
assert_eq!(v.injected_context, "only touch files under src/");
}
#[test]
fn mutation_round_trips_and_tamper_fails() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _s, _p) = mutation_payload(
"tool_input_rewrite",
"call-1",
"shell",
"conv-1",
r#"{"cmd":"rm -rf /"}"#,
r#"{"cmd":"rm /tmp/x"}"#,
&signer,
);
assert_eq!(
verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
Some((
"tool_input_rewrite".to_owned(),
r#"{"cmd":"rm -rf /"}"#.to_owned(),
r#"{"cmd":"rm /tmp/x"}"#.to_owned()
))
);
for field in [
"kind",
"tool_call_id",
"tool_name",
"conversation_id",
"before",
"after",
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = Value::String("EVIL".to_owned());
assert!(
verify_mutation(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail"
);
}
}
#[test]
fn deferred_round_trips_and_tamper_fails() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
assert_eq!(
verify_deferred(&payload),
Some((
"call-1".to_owned(),
"conv-1".to_owned(),
"need more info".to_owned()
))
);
for field in ["request_id", "conversation_id", "reason"] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = Value::String("EVIL".to_owned());
assert!(
verify_deferred(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail"
);
}
}
#[test]
fn wire_verification_round_trips_and_binds_session_and_caller() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = response_payload(
"req-x",
"grep",
r#"{"p":"x"}"#,
"",
true,
true,
&[],
"slack:T1:U9",
"workspace-write",
"go",
"",
"conv-7",
"nonce-7",
&signer,
);
let d = decode_response_full(&payload).expect("decoded payload");
assert!(d.approved_for_session);
assert_eq!(d.caller, "slack:T1:U9");
assert_eq!(d.sandbox_mode, "workspace-write");
assert_eq!(d.conversation_id, "conv-7");
assert_eq!(d.nonce, "nonce-7");
assert!(verify_wire_response(
&d.request_id,
&d.tool_name,
&d.args_json,
"",
d.approved,
d.approved_for_session,
&[],
&d.caller,
&d.sandbox_mode,
&d.reason,
"",
&d.conversation_id,
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
assert!(!verify_wire_response(
&d.request_id,
&d.tool_name,
&d.args_json,
"",
d.approved,
d.approved_for_session,
&[],
"slack:T1:ATTACKER",
&d.sandbox_mode,
&d.reason,
"",
&d.conversation_id,
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
assert!(!verify_wire_response(
&d.request_id,
&d.tool_name,
r#"{"p":"EVIL"}"#,
"",
d.approved,
d.approved_for_session,
&[],
&d.caller,
&d.sandbox_mode,
&d.reason,
"",
&d.conversation_id,
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
assert!(!verify_wire_response(
&d.request_id,
&d.tool_name,
&d.args_json,
"",
d.approved,
d.approved_for_session,
&[],
&d.caller,
&d.sandbox_mode,
&d.reason,
"",
"conv-OTHER",
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
}
#[test]
fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
let signer = ApprovalSigner::from_seed(7);
let (rid, tool, args, caller, mode, conv, nonce) = (
"req-9",
"file_read",
r#"{"path":"a.txt"}"#,
"slack:T1:U9",
"read-only",
"conv-9",
"nonce-9",
);
let shared_reason = auto_review_reason("low");
let human_like = response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
);
let reviewer = response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
);
assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
assert_eq!(human_like.1, reviewer.1, "signature must be identical");
let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
assert!(verified.approved);
assert!(
!verified.approved_for_session,
"a machine decision is never remembered per-caller"
);
assert!(
is_auto_review_reason(&verified.reason),
"the signed reason marks this as an auto-approval"
);
let (human_payload, _s, _p) = response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
mode,
"looks fine",
"",
conv,
nonce,
&signer,
);
let human = verify_signed_response(&human_payload).expect("human verifies");
assert!(!is_auto_review_reason(&human.reason));
let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
let h: Value = serde_json::from_slice(&human_payload).unwrap();
for field in [
"request_id",
"tool_name",
"args_json",
"modified_args_json",
"approved",
"approved_for_session",
"caller",
"sandbox_mode",
"injected_context",
"conversation_id",
"nonce",
] {
assert_eq!(a[field], h[field], "{field} must match the human payload");
}
assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
let base = &reviewer.0;
let base_sig = &reviewer.1;
for (label, variant) in [
(
"tool",
response_payload(
rid,
"file_write",
args,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"args",
response_payload(
rid,
tool,
r#"{"path":"b.txt"}"#,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"caller",
response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
"slack:T1:UEVIL",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"request_id",
response_payload(
"req-OTHER",
tool,
args,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"conversation_id",
response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
"conv-OTHER",
nonce,
&signer,
),
),
(
"nonce",
response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
mode,
&shared_reason,
"",
conv,
"nonce-OTHER",
&signer,
),
),
] {
assert_ne!(
&variant.0, base,
"{label}: a different {label} must change the canonical bytes"
);
assert_ne!(
&variant.1, base_sig,
"{label}: a different {label} must change the signature"
);
}
}
#[test]
fn capability_rejected_across_conversations() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/etc/hosts"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&signer,
);
let consumed = HashSet::new();
assert!(
verify_capability(&payload, "conv-A", &consumed).is_some(),
"a token must verify in the conversation it was signed for"
);
assert!(
verify_capability(&payload, "conv-B", &consumed).is_none(),
"a token signed for conv-A must be rejected when consumed in conv-B"
);
}
#[test]
fn capability_is_single_use() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/etc/hosts"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&signer,
);
let mut consumed = HashSet::new();
let v = verify_capability(&payload, "conv-A", &consumed).expect("first use honored");
assert_eq!(v.nonce, "nonce-A");
consumed.insert(v.nonce.clone());
assert!(
verify_capability(&payload, "conv-A", &consumed).is_none(),
"a spent token must be rejected on re-presentation"
);
}
#[test]
fn capability_authorizes_only_matching_args() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/tmp/scratch"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&signer,
);
let consumed = HashSet::new();
let v = verify_capability(&payload, "conv-A", &consumed).expect("verifies in conv-A");
assert!(
!v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
"a token must not authorize a call with different args"
);
assert!(
!v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
"a token must not authorize a different tool"
);
assert!(
v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
"the exact signed call must be authorized"
);
}
#[test]
fn denied_capability_authorizes_nothing() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
"{}",
"",
false,
false,
&[],
"slack:T1:U9",
"workspace-write",
"deny",
"",
"conv-A",
"nonce-A",
&signer,
);
let consumed = HashSet::new();
let v = verify_capability(&payload, "conv-A", &consumed).expect("verifies");
assert!(!v.approved);
assert!(
!v.authorizes_call("call-0", "delete_file", "{}"),
"a denied token authorizes nothing even on an exact identity match"
);
}
#[test]
fn request_payload_round_trips_id() {
let bytes = request_payload(
"call-7",
"rm",
r#"{"path":"/etc"}"#,
"workspace-write",
"",
&[],
);
assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
assert_eq!(decode_request_reason(&bytes), "");
}
#[test]
fn request_payload_carries_override_reason() {
let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
let bytes = request_payload(
"call-9",
"web_fetch",
r#"{"url":"https://x"}"#,
"",
reason,
&[],
);
assert_eq!(decode_request_reason(&bytes), reason);
assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
assert_eq!(
decode_request_fields(&bytes),
Some((
"call-9".to_owned(),
"web_fetch".to_owned(),
r#"{"url":"https://x"}"#.to_owned()
))
);
}
#[test]
fn receipt_payload_pins_v2_canonical_shape() {
let signer = ApprovalSigner::from_seed(99);
let (reference, amount, currency, recipient, method, timestamp) = (
"tx-abc",
"0.01",
"USDC",
"0xrecipient",
"tempo",
"2026-06-02T00:00:00Z",
);
let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
"outbound_payment_receipt",
"call-1",
"42",
"abcd1234",
"conv-xyz",
);
let expected_canonical = serde_json::json!({
"version": RECEIPT_VERSION,
"kind": kind,
"reference": reference,
"amount": amount,
"currency": currency,
"recipient": recipient,
"method": method,
"timestamp": timestamp,
"tool_call_id": tool_call_id,
"approval_pos": approval_pos,
"approved_args_hash": approved_args_hash,
"subject": subject,
});
let expected_sig = signer.sign(expected_canonical.to_string().as_bytes());
let expected_pk = signer.public_key_bytes();
let expected_full = serde_json::json!({
"version": RECEIPT_VERSION,
"kind": kind,
"reference": reference,
"amount": amount,
"currency": currency,
"recipient": recipient,
"method": method,
"timestamp": timestamp,
"tool_call_id": tool_call_id,
"approval_pos": approval_pos,
"approved_args_hash": approved_args_hash,
"subject": subject,
"signed_by": hex_lower(&expected_pk),
"signature_hex": hex_lower(&expected_sig),
})
.to_string();
let (payload, sig, pk) = receipt_payload(
&ReceiptPayload {
kind,
reference,
amount,
currency,
recipient,
method,
timestamp,
tool_call_id,
approval_pos,
approved_args_hash,
subject,
},
&signer,
);
assert_eq!(
String::from_utf8(payload).unwrap(),
expected_full,
"v2 receipt payload must be byte-identical to the pinned v2 shape"
);
assert_eq!(
sig, expected_sig,
"signature must match the pinned v2 shape"
);
assert_eq!(pk, expected_pk, "public key must be unchanged");
}
#[test]
fn receipt_sign_and_verify_share_one_canonical_source() {
let signer = ApprovalSigner::from_seed(99);
let fields = ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
};
let canonical_bytes = fields.canonical_json().to_string().into_bytes();
let expected_sig = signer.sign(&canonical_bytes);
let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
assert_eq!(
sig, expected_sig,
"receipt_payload must sign exactly ReceiptPayload::canonical_json"
);
let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
let verified = verify_signed_receipt(&payload).expect("verifies");
let verified_canonical = ReceiptPayload {
kind: &verified.kind,
reference: &verified.reference,
amount: &verified.amount,
currency: &verified.currency,
recipient: &verified.recipient,
method: &verified.method,
timestamp: &verified.timestamp,
tool_call_id: &verified.tool_call_id,
approval_pos: &verified.approval_pos,
approved_args_hash: &verified.approved_args_hash,
subject: &verified.subject,
}
.canonical_json()
.to_string()
.into_bytes();
assert_eq!(
verified_canonical, canonical_bytes,
"verify path must derive canonical JSON from the same single source"
);
}
#[test]
fn crypto_receipt_payload_signs_and_verifies() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
},
&signer,
);
let verified =
verify_signed_receipt(&payload).expect("signature verifies on untampered receipt");
assert_eq!(verified.reference, "tx-abc");
assert_eq!(verified.amount, "0.01");
assert_eq!(verified.currency, "USDC");
assert_eq!(verified.recipient, "0xrecipient");
assert_eq!(verified.method, "tempo");
assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
assert_eq!(verified.version, RECEIPT_VERSION);
assert_eq!(verified.kind, "outbound_payment_receipt");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.approval_pos, "42");
assert_eq!(verified.approved_args_hash, "abcd1234");
assert_eq!(verified.subject, "conv-xyz");
}
#[test]
fn tampered_receipt_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
},
&signer,
);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["amount"] = Value::String("9999.00".to_owned());
let tampered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&tampered).is_none());
}
#[test]
fn tampered_receipt_binding_field_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
},
&signer,
);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["approval_pos"] = Value::String("7".to_owned());
let tampered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&tampered).is_none());
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["kind"] = Value::String("payment_receipt".to_owned());
let refiled = v.to_string().into_bytes();
assert!(verify_signed_receipt(&refiled).is_none());
}
#[test]
fn legacy_v1_receipt_still_verifies() {
let signer = ApprovalSigner::from_seed(99);
let canonical = serde_json::json!({
"reference": "tx-old",
"amount": "0.02",
"currency": "USDC",
"recipient": "0xr",
"method": "tempo",
"timestamp": "2026-06-01T00:00:00Z",
});
let sig = signer.sign(canonical.to_string().as_bytes());
let pk = signer.public_key_bytes();
let v1 = serde_json::json!({
"reference": "tx-old",
"amount": "0.02",
"currency": "USDC",
"recipient": "0xr",
"method": "tempo",
"timestamp": "2026-06-01T00:00:00Z",
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&sig),
})
.to_string()
.into_bytes();
let verified = verify_signed_receipt(&v1).expect("a valid v1 receipt still verifies");
assert_eq!(verified.version, 1);
assert_eq!(verified.reference, "tx-old");
assert!(verified.kind.is_empty());
assert!(verified.tool_call_id.is_empty());
assert!(verified.approval_pos.is_empty());
assert!(verified.subject.is_empty());
}
#[test]
fn injected_version_on_v1_signed_receipt_fails() {
let signer = ApprovalSigner::from_seed(99);
let canonical = serde_json::json!({
"reference": "tx-old",
"amount": "0.02",
"currency": "USDC",
"recipient": "0xr",
"method": "tempo",
"timestamp": "2026-06-01T00:00:00Z",
});
let sig = signer.sign(canonical.to_string().as_bytes());
let pk = signer.public_key_bytes();
let mut full = canonical;
full["signed_by"] = Value::String(hex_lower(&pk));
full["signature_hex"] = Value::String(hex_lower(&sig));
for injected in [
Value::from(7_u64),
Value::from(1_u64),
Value::String("2".to_owned()),
] {
let mut tampered = full.clone();
tampered["version"] = injected;
assert!(
verify_signed_receipt(&tampered.to_string().into_bytes()).is_none(),
"a writer-chosen version key must never verify"
);
}
assert!(verify_signed_receipt(&full.to_string().into_bytes()).is_some());
}
}