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 ApprovalSigner {
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn from_seed(seed: u64) -> Self {
Self {
inner: Arc::new(Signer::from_seed(seed)),
}
}
pub fn from_key_bytes(bytes: &[u8]) -> Result<Self, crate::SignerError> {
Ok(Self {
inner: Arc::new(Signer::from_key_bytes(bytes)?),
})
}
#[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 as_signer(&self) -> &Signer {
&self.inner
}
}
#[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,
approver_id: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
) -> Vec<u8> {
let mut v = 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,
});
if !approver_id.is_empty() {
v["approver"] = serde_json::Value::String(approver_id.to_owned());
}
v.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,
approver_id: &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,
approver_id,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
);
let signature = signer.sign(&canonical_bytes);
let pk = signer.public_key_bytes();
let mut 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": crate::hex::lower(&pk),
"signature_hex": crate::hex::lower(&signature),
});
if !approver_id.is_empty() {
full["approver"] = serde_json::Value::String(approver_id.to_owned());
}
(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": crate::hex::lower(&pk),
"signature_hex": crate::hex::lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
fn grant_replay_canonical(
conversation_id: &str,
turn_id: &str,
tool: &str,
grant_ref: &str,
covered_capabilities: &[String],
coverage_hash: &str,
) -> Vec<u8> {
serde_json::json!({
"conversation_id": conversation_id,
"turn_id": turn_id,
"tool": tool,
"grant_ref": grant_ref,
"covered_capabilities": covered_capabilities,
"coverage_hash": coverage_hash,
})
.to_string()
.into_bytes()
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub fn grant_replay_payload(
conversation_id: &str,
turn_id: &str,
tool: &str,
grant_ref: &str,
covered_capabilities: &[String],
coverage_hash: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical = grant_replay_canonical(
conversation_id,
turn_id,
tool,
grant_ref,
covered_capabilities,
coverage_hash,
);
let signature = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"conversation_id": conversation_id,
"turn_id": turn_id,
"tool": tool,
"grant_ref": grant_ref,
"covered_capabilities": covered_capabilities,
"coverage_hash": coverage_hash,
"signed_by": crate::hex::lower(&pk),
"signature_hex": crate::hex::lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
#[must_use]
pub fn verify_grant_replay(payload: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(payload) else {
return false;
};
let (
Some(conversation_id),
Some(turn_id),
Some(tool),
Some(grant_ref),
Some(covered),
Some(coverage_hash),
Some(signed_by),
Some(signature_hex),
) = (
v.get("conversation_id").and_then(Value::as_str),
v.get("turn_id").and_then(Value::as_str),
v.get("tool").and_then(Value::as_str),
v.get("grant_ref").and_then(Value::as_str),
v.get("covered_capabilities").and_then(Value::as_array),
v.get("coverage_hash").and_then(Value::as_str),
v.get("signed_by").and_then(Value::as_str),
v.get("signature_hex").and_then(Value::as_str),
)
else {
return false;
};
let Some(covered_capabilities) = covered
.iter()
.map(|c| c.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()
else {
return false;
};
let (Some(pk), Some(sig)) = (
crate::hex::decode(signed_by),
crate::hex::decode(signature_hex),
) else {
return false;
};
let canonical = grant_replay_canonical(
conversation_id,
turn_id,
tool,
grant_ref,
&covered_capabilities,
coverage_hash,
);
crate::verify(&pk, &canonical, &sig)
}
fn signer_is_trusted(signer_pk: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
trusted_signers.iter().any(|k| k.as_slice() == signer_pk)
}
#[must_use]
pub fn verify_grant_replay_pinned(payload: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
let Some(pk) = serde_json::from_slice::<Value>(payload).ok().and_then(|v| {
v.get("signed_by")
.and_then(Value::as_str)
.and_then(crate::hex::decode)
}) else {
return false;
};
if !signer_is_trusted(&pk, trusted_signers) {
return false;
}
verify_grant_replay(payload)
}
#[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 = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::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": crate::hex::lower(&pk),
"signature_hex": crate::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 = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::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": crate::hex::lower(&pk),
"signature_hex": crate::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 = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::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 approver: 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 approver_id = v
.get("approver")
.and_then(|x| x.as_str())
.unwrap_or_default()
.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 = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::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,
&approver_id,
&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,
approver: approver_id,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn verify_signed_response_pinned(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedResponse> {
let verified = verify_signed_response(payload)?;
if !signer_is_trusted(&verified.signer_public_key, trusted_signers) {
return None;
}
Some(verified)
}
#[must_use]
pub fn verify_capability<S: std::hash::BuildHasher>(
payload: &[u8],
conversation_id: &str,
consumed: &HashSet<String, S>,
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedResponse> {
let verified = verify_signed_response_pinned(payload, trusted_signers)?;
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,
approver_id: &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) = crate::hex::decode(signer_pk_hex) else {
return false;
};
let Some(sig) = crate::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,
approver_id,
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 approver: 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(),
approver: v
.get("approver")
.and_then(|x| x.as_str())
.unwrap_or_default()
.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(crate::hex::lower(&pk)),
);
map.insert(
"signature_hex".to_owned(),
Value::String(crate::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],
trusted_signers: &[Vec<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 = crate::hex::decode(signed_by_hex)?;
let sig = crate::hex::decode(signature_hex)?;
if !signer_is_trusted(&pk, trusted_signers) {
return None;
}
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
}
}
pub const RESOLVE_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
fn resolve_token_canonical(request_id: &str, conversation_id: &str, minted_at_ms: u64) -> Vec<u8> {
serde_json::json!({
"request_id": request_id,
"conversation_id": conversation_id,
"minted_at_ms": minted_at_ms,
})
.to_string()
.into_bytes()
}
#[must_use]
pub fn mint_resolve_token(
request_id: &str,
conversation_id: &str,
minted_at_ms: u64,
signer: &ApprovalSigner,
) -> String {
let canonical = resolve_token_canonical(request_id, conversation_id, minted_at_ms);
let signature = signer.sign(&canonical);
let full = serde_json::json!({
"request_id": request_id,
"conversation_id": conversation_id,
"minted_at_ms": minted_at_ms,
"signature_hex": crate::hex::lower(&signature),
});
crate::hex::lower(full.to_string().as_bytes())
}
#[must_use]
pub fn verify_resolve_token(
token: &str,
request_id: &str,
conversation_id: &str,
now_ms: u64,
signer: &ApprovalSigner,
) -> bool {
let Some(bytes) = crate::hex::decode(token) else {
return false;
};
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
return false;
};
let (
Some(bound_request_id),
Some(bound_conversation_id),
Some(minted_at_ms),
Some(signature_hex),
) = (
v.get("request_id").and_then(Value::as_str),
v.get("conversation_id").and_then(Value::as_str),
v.get("minted_at_ms").and_then(Value::as_u64),
v.get("signature_hex").and_then(Value::as_str),
)
else {
return false;
};
if bound_request_id != request_id || bound_conversation_id != conversation_id {
return false;
}
let elapsed = now_ms.abs_diff(minted_at_ms);
if elapsed > RESOLVE_TOKEN_TTL_MS {
return false;
}
let Some(sig) = crate::hex::decode(signature_hex) else {
return false;
};
let canonical = resolve_token_canonical(bound_request_id, bound_conversation_id, minted_at_ms);
verify(&signer.public_key_bytes(), &canonical, &sig)
}
fn admin_model_change_canonical(
principal: &str,
previous_provider: &str,
previous_model: &str,
new_provider: &str,
new_model: &str,
changed_at_ms: u64,
) -> Vec<u8> {
serde_json::json!({
"principal": principal,
"previous_provider": previous_provider,
"previous_model": previous_model,
"new_provider": new_provider,
"new_model": new_model,
"changed_at_ms": changed_at_ms,
})
.to_string()
.into_bytes()
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub fn admin_model_change_payload(
principal: &str,
previous_provider: &str,
previous_model: &str,
new_provider: &str,
new_model: &str,
changed_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical = admin_model_change_canonical(
principal,
previous_provider,
previous_model,
new_provider,
new_model,
changed_at_ms,
);
let signature = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"principal": principal,
"previous_provider": previous_provider,
"previous_model": previous_model,
"new_provider": new_provider,
"new_model": new_model,
"changed_at_ms": changed_at_ms,
"signed_by": crate::hex::lower(&pk),
"signature_hex": crate::hex::lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAdminModelChange {
pub principal: String,
pub previous_provider: String,
pub previous_model: String,
pub new_provider: String,
pub new_model: String,
pub changed_at_ms: u64,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn verify_admin_model_change(payload: &[u8]) -> Option<VerifiedAdminModelChange> {
let v: Value = serde_json::from_slice(payload).ok()?;
let principal = v.get("principal")?.as_str()?.to_owned();
let previous_provider = v.get("previous_provider")?.as_str()?.to_owned();
let previous_model = v.get("previous_model")?.as_str()?.to_owned();
let new_provider = v.get("new_provider")?.as_str()?.to_owned();
let new_model = v.get("new_model")?.as_str()?.to_owned();
let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = admin_model_change_canonical(
&principal,
&previous_provider,
&previous_model,
&new_provider,
&new_model,
changed_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedAdminModelChange {
principal,
previous_provider,
previous_model,
new_provider,
new_model,
changed_at_ms,
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()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn loaded_key_signature_verifies_and_from_seed_signature_does_not() {
let key_bytes = [42u8; 32];
let loaded = ApprovalSigner::from_key_bytes(&key_bytes).expect("valid key material");
let forged = ApprovalSigner::from_seed(1);
assert_ne!(
loaded.public_key_bytes(),
forged.public_key_bytes(),
"a loaded key must not collide with the public, deterministic seed-1 key"
);
let (payload, _sig, _pk) = response_payload(
"req-1",
"web_fetch",
r#"{"url":"https://a"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"",
"",
"conv-1",
"nonce-1",
&loaded,
);
let verified = verify_signed_response(&payload).expect("verifies under the loaded key");
assert_eq!(verified.signer_public_key, loaded.public_key_bytes());
let (forged_payload, _sig, _pk) = response_payload(
"req-1",
"web_fetch",
r#"{"url":"https://a"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"",
"",
"conv-1",
"nonce-1",
&forged,
);
let mut v: serde_json::Value = serde_json::from_slice(&forged_payload).unwrap();
v["signed_by"] = serde_json::json!(crate::hex::lower(&loaded.public_key_bytes()));
assert!(
verify_signed_response(v.to_string().as_bytes()).is_none(),
"a from_seed(1) signature must not verify against the loaded key"
);
}
#[test]
fn grant_replay_audit_is_signed_and_tamper_evident() {
let signer = ApprovalSigner::from_seed(9);
let covered = vec!["arbitrary-egress".to_owned()];
let (payload, _sig, _pk) = grant_replay_payload(
"conv-1",
"turn-7",
"post_summary",
"deadbeef",
&covered,
"sha256:template-abc",
&signer,
);
assert!(verify_grant_replay(&payload), "the genuine record verifies");
for (field, val) in [
("conversation_id", serde_json::json!("conv-EVIL")),
("turn_id", serde_json::json!("turn-8")),
("tool", serde_json::json!("exfiltrate")),
("grant_ref", serde_json::json!("cafe")),
(
"covered_capabilities",
serde_json::json!(["arbitrary-egress", "mutate-external"]),
),
("coverage_hash", serde_json::json!("sha256:other")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
!verify_grant_replay(v.to_string().as_bytes()),
"tampered {field} must fail verification"
);
}
assert!(!verify_grant_replay(b"not json"));
}
#[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 pre_approver_field_payload_still_verifies_unchanged() {
let signer = ApprovalSigner::from_seed(42);
let (request_id, tool_name, args_json, modified_args_json) =
("req-1", "tool.name", r#"{"a":1}"#, "");
let (approved, approved_for_session) = (true, true);
let covered_capabilities = ["cap.a".to_owned(), "cap.b".to_owned()];
let caller = "persona-caller";
let sandbox_mode = "sandboxed";
let reason = "looks fine";
let injected_context = "";
let conversation_id = "conv-1";
let nonce = "nonce-1";
let pre_approver_canonical = 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,
});
let expected_sig = signer.sign(pre_approver_canonical.to_string().as_bytes());
let expected_pk = signer.public_key_bytes();
let pre_approver_payload = 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": crate::hex::lower(&expected_pk),
"signature_hex": crate::hex::lower(&expected_sig),
})
.to_string()
.into_bytes();
let verified = verify_signed_response(&pre_approver_payload)
.expect("a pre-#1025 payload must still verify");
assert_eq!(verified.request_id, request_id);
assert_eq!(verified.caller, caller);
assert_eq!(
verified.approver, "",
"no approver field existed on this payload — decodes to empty, not an error"
);
let (regenerated, sig, pk) = response_payload(
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,
);
assert_eq!(
regenerated, pre_approver_payload,
"an empty approver must produce byte-identical canonical/payload to before #1025"
);
assert_eq!(
sig, expected_sig,
"an empty approver must sign byte-identically to before #1025"
);
assert_eq!(pk, expected_pk, "public key must be unchanged");
}
#[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.approver,
&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.approver,
&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.approver,
&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.approver,
&d.sandbox_mode,
&d.reason,
"",
"conv-OTHER",
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
}
#[test]
fn approver_is_recoverable_and_distinct_from_caller() {
let signer = ApprovalSigner::from_seed(3);
let (self_approved, ..) = response_payload(
"req-1",
"grep",
r#"{"q":"x"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"slack:T1:U9",
"workspace-write",
"self-approved",
"",
"conv-1",
"nonce-1",
&signer,
);
let self_decoded = decode_response_full(&self_approved).expect("decodes");
assert_eq!(self_decoded.caller, "slack:T1:U9");
assert_eq!(self_decoded.approver, "slack:T1:U9");
let self_verified = verify_signed_response(&self_approved).expect("verifies");
assert_eq!(self_verified.caller, self_verified.approver);
let (admin_approved, ..) = response_payload(
"req-2",
"rm",
r#"{"path":"/tmp/x"}"#,
"",
true,
false,
&[],
"slack:T1:BENEFICIARY",
"slack:T1:ADMIN",
"workspace-write",
"approved on your behalf",
"",
"conv-2",
"nonce-2",
&signer,
);
let admin_decoded = decode_response_full(&admin_approved).expect("decodes");
assert_eq!(admin_decoded.caller, "slack:T1:BENEFICIARY");
assert_eq!(admin_decoded.approver, "slack:T1:ADMIN");
assert_ne!(
admin_decoded.caller, admin_decoded.approver,
"admin-approves-for-someone-else must decode two DISTINCT identities"
);
let admin_verified = verify_signed_response(&admin_approved).expect("verifies");
assert_eq!(admin_verified.caller, "slack:T1:BENEFICIARY");
assert_eq!(admin_verified.approver, "slack:T1:ADMIN");
let mut v: serde_json::Value = serde_json::from_slice(&admin_approved).unwrap();
v["approver"] = serde_json::json!("slack:T1:ATTACKER");
assert!(
verify_signed_response(v.to_string().as_bytes()).is_none(),
"a tampered approver must fail verification"
);
}
#[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, &[signer.public_key_bytes()])
.is_some(),
"a token must verify in the conversation it was signed for"
);
assert!(
verify_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
.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, &[signer.public_key_bytes()])
.expect("first use honored");
assert_eq!(v.nonce, "nonce-A");
consumed.insert(v.nonce.clone());
assert!(
verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.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, &[signer.public_key_bytes()])
.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, &[signer.public_key_bytes()])
.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": crate::hex::lower(&expected_pk),
"signature_hex": crate::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 trusted = vec![signer.public_key_bytes()];
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, &trusted).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 trusted = vec![signer.public_key_bytes()];
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, &trusted)
.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 trusted = vec![signer.public_key_bytes()];
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, &trusted).is_none());
}
#[test]
fn tampered_receipt_binding_field_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
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, &trusted).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, &trusted).is_none());
}
#[test]
fn receipt_from_non_allowlisted_signer_is_rejected() {
let trusted_signer = ApprovalSigner::from_seed(99);
let attacker_signer = ApprovalSigner::from_seed(31337);
let fields = ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-forged",
amount: "100.00",
currency: "USDC",
recipient: "0xattacker",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
};
let (payload, _sig, _pk) = receipt_payload(&fields, &attacker_signer);
let trusted = vec![trusted_signer.public_key_bytes()];
assert!(
verify_signed_receipt(&payload, &trusted).is_none(),
"a receipt signed by a non-allow-listed key must not verify"
);
let trusted_plus_attacker = vec![
trusted_signer.public_key_bytes(),
attacker_signer.public_key_bytes(),
];
assert!(
verify_signed_receipt(&payload, &trusted_plus_attacker).is_some(),
"the same receipt must verify once its signer is allow-listed"
);
assert!(verify_signed_receipt(&payload, &[]).is_none());
}
#[test]
fn grant_replay_from_non_allowlisted_signer_is_rejected() {
let trusted_signer = ApprovalSigner::from_seed(99);
let attacker_signer = ApprovalSigner::from_seed(31337);
let covered = vec!["arbitrary-egress".to_owned()];
let (payload, _sig, _pk) = grant_replay_payload(
"conv-1",
"turn-7",
"post_summary",
"deadbeef",
&covered,
"sha256:template-abc",
&attacker_signer,
);
assert!(
verify_grant_replay(&payload),
"the unpinned verifier trusts any self-consistent signature"
);
let trusted = vec![trusted_signer.public_key_bytes()];
assert!(
!verify_grant_replay_pinned(&payload, &trusted),
"a grant_replay signed by a non-allow-listed key must not verify"
);
let trusted_plus_attacker = vec![
trusted_signer.public_key_bytes(),
attacker_signer.public_key_bytes(),
];
assert!(
verify_grant_replay_pinned(&payload, &trusted_plus_attacker),
"the same record must verify once its signer is allow-listed"
);
assert!(!verify_grant_replay_pinned(&payload, &[]));
}
#[test]
fn signed_response_from_non_allowlisted_signer_is_rejected() {
let trusted_signer = ApprovalSigner::from_seed(99);
let attacker_signer = ApprovalSigner::from_seed(31337);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/etc/hosts"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&attacker_signer,
);
assert!(
verify_signed_response(&payload).is_some(),
"the unpinned verifier trusts any self-consistent signature"
);
let trusted = vec![trusted_signer.public_key_bytes()];
let consumed = HashSet::new();
assert!(
verify_signed_response_pinned(&payload, &trusted).is_none(),
"an approval_response signed by a non-allow-listed key must not verify"
);
assert!(
verify_capability(&payload, "conv-A", &consumed, &trusted).is_none(),
"the capability gate must reject a non-allow-listed signer"
);
let trusted_plus_attacker = vec![
trusted_signer.public_key_bytes(),
attacker_signer.public_key_bytes(),
];
assert!(
verify_signed_response_pinned(&payload, &trusted_plus_attacker).is_some(),
"the same response must verify once its signer is allow-listed"
);
assert!(
verify_capability(&payload, "conv-A", &consumed, &trusted_plus_attacker).is_some(),
"the capability gate honors an allow-listed signer"
);
assert!(verify_signed_response_pinned(&payload, &[]).is_none());
assert!(verify_capability(&payload, "conv-A", &consumed, &[]).is_none());
}
#[test]
fn legacy_v1_receipt_still_verifies() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
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": crate::hex::lower(&pk),
"signature_hex": crate::hex::lower(&sig),
})
.to_string()
.into_bytes();
let verified =
verify_signed_receipt(&v1, &trusted).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 trusted = vec![signer.public_key_bytes()];
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(crate::hex::lower(&pk));
full["signature_hex"] = Value::String(crate::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(), &trusted).is_none(),
"a writer-chosen version key must never verify"
);
}
assert!(verify_signed_receipt(&full.to_string().into_bytes(), &trusted).is_some());
}
}
#[cfg(test)]
mod resolve_token_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn resolve_token_verifies_for_its_own_request_and_conversation() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(verify_resolve_token(
&token, "call-1", "conv-a", 1_000, &signer
));
}
#[test]
fn resolve_token_rejects_a_different_request_id() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(!verify_resolve_token(
&token, "call-2", "conv-a", 1_000, &signer
));
}
#[test]
fn resolve_token_rejects_a_different_conversation() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(!verify_resolve_token(
&token, "call-1", "conv-b", 1_000, &signer
));
}
#[test]
fn resolve_token_rejects_wrong_signer() {
let signer = ApprovalSigner::from_seed(11);
let other = ApprovalSigner::from_seed(12);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(!verify_resolve_token(
&token, "call-1", "conv-a", 1_000, &other
));
}
#[test]
fn resolve_token_rejects_after_ttl_elapses() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 0, &signer);
assert!(verify_resolve_token(
&token,
"call-1",
"conv-a",
RESOLVE_TOKEN_TTL_MS,
&signer
));
assert!(!verify_resolve_token(
&token,
"call-1",
"conv-a",
RESOLVE_TOKEN_TTL_MS + 1,
&signer
));
}
#[test]
fn resolve_token_rejects_garbage() {
let signer = ApprovalSigner::from_seed(11);
assert!(!verify_resolve_token(
"not-hex", "call-1", "conv-a", 0, &signer
));
assert!(!verify_resolve_token("", "call-1", "conv-a", 0, &signer));
}
}
#[cfg(test)]
mod admin_model_change_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn admin_model_change_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(31);
let (payload, _sig, _pk) = admin_model_change_payload(
"team-a",
"vertex",
"old-model",
"vertex",
"new-model",
1_000,
&signer,
);
let verified = verify_admin_model_change(&payload).expect("genuine record verifies");
assert_eq!(verified.principal, "team-a");
assert_eq!(verified.new_model, "new-model");
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("principal", serde_json::json!("attacker")),
("new_model", serde_json::json!("evil-model")),
("new_provider", serde_json::json!("evil-provider")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_admin_model_change(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
}