use polyc_crypto::approval;
use polyc_eventlog::Event;
use polyc_proto::kinds;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalSignatureStatus {
Verified,
Invalid,
LegacyUnverifiable,
}
#[must_use]
pub fn classify_response_signature(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> ApprovalSignatureStatus {
if approval::verify_signed_response_pinned(payload, trusted_signers).is_some() {
return ApprovalSignatureStatus::Verified;
}
let is_legacy = serde_json::from_slice::<serde_json::Value>(payload)
.ok()
.is_some_and(|v| v.get("request_id").is_some() && v.get("tool_name").is_none());
if is_legacy {
ApprovalSignatureStatus::LegacyUnverifiable
} else {
ApprovalSignatureStatus::Invalid
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalRequestFact {
pub request_id: String,
pub tool_name: String,
pub args_json: String,
pub reason: String,
pub sandbox_mode: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalResponseFact {
pub request_id: String,
pub approved: bool,
pub response_reason: Option<String>,
pub signature_status: ApprovalSignatureStatus,
pub signer_public_key: Option<Vec<u8>>,
pub modified_args_json: Option<String>,
pub approved_for_session: Option<bool>,
pub caller: Option<String>,
pub approver: Option<String>,
pub sandbox_mode: Option<String>,
pub injected_context: Option<String>,
pub tool_name: Option<String>,
pub routine_grant: Option<bool>,
pub tool_descriptor_hash: Option<String>,
pub grant_scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalFact {
Request(ApprovalRequestFact),
Response(ApprovalResponseFact),
}
#[must_use]
pub fn fold_approval_event(event: &Event, trusted_signers: &[Vec<u8>]) -> Option<ApprovalFact> {
let (base, _turn_id) = kinds::parse(&event.kind);
if base == kinds::APPROVAL_REQUEST {
fold_approval_request(&event.payload).map(ApprovalFact::Request)
} else if base == kinds::APPROVAL_RESPONSE {
fold_approval_response(&event.payload, trusted_signers).map(ApprovalFact::Response)
} else {
None
}
}
fn fold_approval_request(payload: &[u8]) -> Option<ApprovalRequestFact> {
let (request_id, tool_name, args_json) = approval::decode_request_fields(payload)?;
Some(ApprovalRequestFact {
request_id,
tool_name,
args_json,
reason: approval::decode_request_reason(payload),
sandbox_mode: approval::decode_request_sandbox_mode(payload),
})
}
fn fold_approval_response(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<ApprovalResponseFact> {
let (request_id, approved) = approval::decode_response_minimal(payload)?;
let signature_status = classify_response_signature(payload, trusted_signers);
let full = approval::decode_response_full(payload);
let signer_public_key = full.as_ref().and_then(|d| {
if d.signer_pk_hex.is_empty() {
None
} else {
polyc_crypto::hex::decode(&d.signer_pk_hex)
}
});
Some(ApprovalResponseFact {
request_id,
approved,
response_reason: full.as_ref().map(|d| d.reason.clone()),
signature_status,
signer_public_key,
modified_args_json: full.as_ref().map(|d| d.modified_args_json.clone()),
approved_for_session: full.as_ref().map(|d| d.approved_for_session),
caller: full.as_ref().map(|d| d.caller.clone()),
approver: full.as_ref().map(|d| d.approver.clone()),
sandbox_mode: full.as_ref().map(|d| d.sandbox_mode.clone()),
injected_context: full.as_ref().map(|d| d.injected_context.clone()),
tool_name: full.as_ref().map(|d| d.tool_name.clone()),
routine_grant: full.as_ref().map(|d| d.routine_grant),
tool_descriptor_hash: full.as_ref().map(|d| d.tool_descriptor_hash.clone()),
grant_scope: full.as_ref().map(|d| d.grant_scope.clone()),
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
use polyc_crypto::approval::{ApprovalSigner, response_payload};
use super::*;
fn request_event(request_id: &str, tool_name: &str, args_json: &str) -> Event {
Event::new(
kinds::APPROVAL_REQUEST.to_owned(),
approval::request_payload(
request_id,
tool_name,
args_json,
"default",
"",
&[],
"",
"",
"",
&[],
false,
),
)
}
#[allow(clippy::too_many_arguments)]
fn response_bytes(
request_id: &str,
tool_name: &str,
args_json: &str,
approved: bool,
caller: &str,
reason: &str,
signer: &ApprovalSigner,
) -> Vec<u8> {
response_payload(
request_id,
tool_name,
args_json,
"",
approved,
false,
&[],
caller,
"",
"",
reason,
"",
"conv-1",
"nonce-1",
"",
signer,
)
.0
}
#[test]
fn fold_approval_event_decodes_a_request() {
let event = request_event("call-1", "rm", r#"{"path":"/tmp"}"#);
let fact = fold_approval_event(&event, &[]).expect("request decodes");
match fact {
ApprovalFact::Request(req) => {
assert_eq!(req.request_id, "call-1");
assert_eq!(req.tool_name, "rm");
assert_eq!(req.args_json, r#"{"path":"/tmp"}"#);
}
ApprovalFact::Response(_) => panic!("expected Request"),
}
}
#[test]
fn fold_approval_event_keeps_an_untrusted_signer_response_tagged_invalid() {
let trusted = ApprovalSigner::from_seed(1);
let untrusted = ApprovalSigner::from_seed(2);
let bytes = response_bytes(
"call-1",
"rm",
r#"{"path":"/tmp"}"#,
true,
"caller-1",
"ok",
&untrusted,
);
let event = Event::new(kinds::APPROVAL_RESPONSE.to_owned(), bytes);
let fact = fold_approval_event(&event, &[trusted.public_key_bytes()])
.expect("a structurally valid response always folds to a row");
match fact {
ApprovalFact::Response(resp) => {
assert_eq!(resp.signature_status, ApprovalSignatureStatus::Invalid);
assert!(
resp.approved,
"the row is still present with its claimed fields"
);
assert_eq!(resp.caller.as_deref(), Some("caller-1"));
}
ApprovalFact::Request(_) => panic!("expected Response"),
}
}
#[test]
fn fold_approval_event_verifies_a_trusted_signer_response() {
let signer = ApprovalSigner::from_seed(3);
let bytes = response_bytes(
"call-2",
"rm",
r#"{"path":"/tmp"}"#,
true,
"caller-2",
"ok",
&signer,
);
let event = Event::new(kinds::APPROVAL_RESPONSE.to_owned(), bytes);
let fact = fold_approval_event(&event, &[signer.public_key_bytes()]).expect("decodes");
match fact {
ApprovalFact::Response(resp) => {
assert_eq!(resp.signature_status, ApprovalSignatureStatus::Verified);
assert_eq!(
resp.signer_public_key.as_deref(),
Some(signer.public_key_bytes().as_slice())
);
}
ApprovalFact::Request(_) => panic!("expected Response"),
}
}
#[test]
fn fold_approval_event_reads_a_legacy_response_with_only_identity_and_classification() {
let legacy = br#"{"request_id":"call-3","approved":true,"reason":"ok"}"#.to_vec();
let event = Event::new(kinds::APPROVAL_RESPONSE.to_owned(), legacy);
let fact = fold_approval_event(&event, &[]).expect("legacy still decodes minimally");
match fact {
ApprovalFact::Response(resp) => {
assert_eq!(resp.request_id, "call-3");
assert!(resp.approved);
assert_eq!(
resp.signature_status,
ApprovalSignatureStatus::LegacyUnverifiable
);
assert_eq!(resp.response_reason, None);
assert_eq!(resp.signer_public_key, None);
assert_eq!(resp.caller, None);
assert_eq!(resp.tool_name, None);
assert_eq!(resp.routine_grant, None);
assert_eq!(resp.tool_descriptor_hash, None);
assert_eq!(resp.grant_scope, None);
}
ApprovalFact::Request(_) => panic!("expected Response"),
}
}
#[test]
fn fold_approval_event_ordinary_response_has_no_grant_marker() {
let signer = ApprovalSigner::from_seed(11);
let bytes = response_bytes(
"call-5",
"rm",
r#"{"path":"/tmp"}"#,
true,
"caller-5",
"ok",
&signer,
);
let event = Event::new(kinds::APPROVAL_RESPONSE.to_owned(), bytes);
let fact = fold_approval_event(&event, &[signer.public_key_bytes()]).expect("decodes");
match fact {
ApprovalFact::Response(resp) => {
assert_eq!(resp.tool_name.as_deref(), Some("rm"));
assert_eq!(resp.routine_grant, Some(false));
assert_eq!(resp.tool_descriptor_hash.as_deref(), Some(""));
assert_eq!(resp.grant_scope.as_deref(), Some(""));
}
ApprovalFact::Request(_) => panic!("expected Response"),
}
}
#[test]
fn fold_approval_event_decodes_a_routine_grant_response() {
let signer = ApprovalSigner::from_seed(12);
let (payload, ..) = polyc_crypto::approval::routine_grant_payload(
"call-6",
"fs_write",
"{}",
"",
true,
"owner-1",
"",
"default",
"",
&[],
"conv-fire-1",
"nonce-6",
"",
"hash-abc",
"tool",
&signer,
);
let event = Event::new(kinds::APPROVAL_RESPONSE.to_owned(), payload);
let fact = fold_approval_event(&event, &[signer.public_key_bytes()]).expect("decodes");
match fact {
ApprovalFact::Response(resp) => {
assert_eq!(resp.tool_name.as_deref(), Some("fs_write"));
assert_eq!(resp.routine_grant, Some(true));
assert_eq!(resp.tool_descriptor_hash.as_deref(), Some("hash-abc"));
assert_eq!(resp.grant_scope.as_deref(), Some("tool"));
}
ApprovalFact::Request(_) => panic!("expected Response"),
}
}
#[test]
fn fold_approval_event_drops_a_structurally_malformed_response() {
let event = Event::new(kinds::APPROVAL_RESPONSE.to_owned(), vec![0xFF, 0xFE]);
assert_eq!(fold_approval_event(&event, &[]), None);
}
#[test]
fn fold_approval_event_drops_a_structurally_malformed_request() {
let event = Event::new(
kinds::APPROVAL_REQUEST.to_owned(),
br#"{"tool_name":"rm"}"#.to_vec(),
);
assert_eq!(fold_approval_event(&event, &[]), None);
}
#[test]
fn fold_approval_event_ignores_unrelated_kinds() {
let event = Event::new(kinds::USAGE.to_owned(), Vec::new());
assert_eq!(fold_approval_event(&event, &[]), None);
}
}