use serde::{Deserialize, Serialize};
use crate::deny_reason::DenyReason;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityDecision {
Allow,
Deny,
Diagnose,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityDenyCode {
MissingContract,
LockedVault,
MissingAgent,
MissingRequiredSecret,
BadWorkdir,
TargetDenied,
PathEscape,
BlankScope,
ProfileOverride,
ContractOverride,
RequestTimeWidening,
NetworkUnenforced,
AuditUnavailable,
OutputCap,
Timeout,
HostSchemaUnstable,
ConfigStale,
ProofUnavailable,
ParseError,
InternalError,
}
impl From<DenyReason> for AuthorityDenyCode {
fn from(reason: DenyReason) -> Self {
match reason {
DenyReason::TargetNotAllowed | DenyReason::TargetMissing => Self::TargetDenied,
DenyReason::RequiredSecretNotFound | DenyReason::AllowedSecretNotFound => {
Self::MissingRequiredSecret
}
DenyReason::DangerousEnvVariable => Self::RequestTimeWidening,
DenyReason::VaultProfileNotFound
| DenyReason::NamespaceMissing
| DenyReason::AccessProfileViolation => Self::MissingRequiredSecret,
DenyReason::NetworkPolicyViolation | DenyReason::NetworkUnenforced => {
Self::NetworkUnenforced
}
DenyReason::InsufficientAuthority => Self::BlankScope,
DenyReason::MissingContract => Self::MissingContract,
DenyReason::LockedVault => Self::LockedVault,
DenyReason::MissingAgent => Self::MissingAgent,
DenyReason::BadWorkdir => Self::BadWorkdir,
DenyReason::PathEscape => Self::PathEscape,
DenyReason::BlankScope => Self::BlankScope,
DenyReason::ProfileOverride => Self::ProfileOverride,
DenyReason::ContractOverride => Self::ContractOverride,
DenyReason::RequestTimeWidening => Self::RequestTimeWidening,
DenyReason::AuditUnavailable => Self::AuditUnavailable,
DenyReason::OutputCap => Self::OutputCap,
DenyReason::Timeout => Self::Timeout,
DenyReason::HostSchemaUnstable => Self::HostSchemaUnstable,
DenyReason::ConfigStale => Self::ConfigStale,
DenyReason::ProofUnavailable => Self::ProofUnavailable,
DenyReason::ParseError => Self::ParseError,
DenyReason::InternalError => Self::InternalError,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthorityMode {
BoundMcp,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AuthorityMetadata {
pub profile: String,
pub contract: String,
pub workdir: String,
pub mode: AuthorityMode,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AuthorityRefusal {
pub summary: String,
pub detail: String,
pub next_actions: Vec<String>,
pub code: AuthorityDenyCode,
pub authority: AuthorityMetadata,
pub receipt_id: String,
}
impl AuthorityRefusal {
pub fn new(
summary: impl Into<String>,
detail: impl Into<String>,
next_actions: Vec<String>,
code: AuthorityDenyCode,
authority: AuthorityMetadata,
receipt_id: impl Into<String>,
) -> Self {
Self {
summary: summary.into(),
detail: detail.into(),
next_actions,
code,
authority,
receipt_id: receipt_id.into(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AuthorityCommandIdentity {
pub display: String,
pub target: String,
pub argv_hash: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AuthorityReceipt {
pub receipt_id: String,
pub run_id: String,
pub audit_join_key: String,
pub decision: AuthorityDecision,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<AuthorityDenyCode>,
pub command: AuthorityCommandIdentity,
pub authority: AuthorityMetadata,
pub started_at: String,
pub finished_at: String,
pub truncated_stdout: bool,
pub truncated_stderr: bool,
pub redaction_policy: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_authority_decision_code_and_mode_as_snake_case() {
assert_eq!(
serde_json::to_string(&AuthorityDecision::Allow).unwrap(),
"\"allow\""
);
assert_eq!(
serde_json::to_string(&AuthorityDecision::Deny).unwrap(),
"\"deny\""
);
assert_eq!(
serde_json::to_string(&AuthorityDecision::Diagnose).unwrap(),
"\"diagnose\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::MissingContract).unwrap(),
"\"missing_contract\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::LockedVault).unwrap(),
"\"locked_vault\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::MissingAgent).unwrap(),
"\"missing_agent\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::MissingRequiredSecret).unwrap(),
"\"missing_required_secret\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::BadWorkdir).unwrap(),
"\"bad_workdir\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::TargetDenied).unwrap(),
"\"target_denied\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::PathEscape).unwrap(),
"\"path_escape\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::BlankScope).unwrap(),
"\"blank_scope\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::ProfileOverride).unwrap(),
"\"profile_override\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::ContractOverride).unwrap(),
"\"contract_override\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::RequestTimeWidening).unwrap(),
"\"request_time_widening\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::NetworkUnenforced).unwrap(),
"\"network_unenforced\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::AuditUnavailable).unwrap(),
"\"audit_unavailable\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::OutputCap).unwrap(),
"\"output_cap\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::Timeout).unwrap(),
"\"timeout\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::HostSchemaUnstable).unwrap(),
"\"host_schema_unstable\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::ConfigStale).unwrap(),
"\"config_stale\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::ProofUnavailable).unwrap(),
"\"proof_unavailable\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::ParseError).unwrap(),
"\"parse_error\""
);
assert_eq!(
serde_json::to_string(&AuthorityDenyCode::InternalError).unwrap(),
"\"internal_error\""
);
assert_eq!(
serde_json::to_string(&AuthorityMode::BoundMcp).unwrap(),
"\"bound_mcp\""
);
}
#[test]
fn refusal_and_receipt_roundtrip_with_snake_case_fields() {
let authority = AuthorityMetadata {
profile: "profile-name".to_string(),
contract: "contract-name".to_string(),
workdir: "C:\\Users\\0ryant\\prj\\example".to_string(),
mode: AuthorityMode::BoundMcp,
};
let refusal = AuthorityRefusal::new(
"Short model-safe refusal sentence.",
"Operator-actionable explanation without secret values.",
vec!["Concrete remediation step.".to_string()],
AuthorityDenyCode::TargetDenied,
authority.clone(),
"rcpt_123",
);
let refusal_json = serde_json::to_string(&refusal).unwrap();
assert!(refusal_json.contains("\"next_actions\""));
assert!(refusal_json.contains("\"receipt_id\""));
assert!(refusal_json.contains("\"target_denied\""));
let decoded_refusal: AuthorityRefusal = serde_json::from_str(&refusal_json).unwrap();
assert_eq!(decoded_refusal, refusal);
let receipt = AuthorityReceipt {
receipt_id: "rcpt_123".to_string(),
run_id: "run_123".to_string(),
audit_join_key: "audit_123".to_string(),
decision: AuthorityDecision::Deny,
code: Some(AuthorityDenyCode::TargetDenied),
command: AuthorityCommandIdentity {
display: "cordance check".to_string(),
target: "C:\\Tools\\cordance\\cordance.exe".to_string(),
argv_hash:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
.to_string(),
},
authority,
started_at: "2026-05-18T00:00:00Z".to_string(),
finished_at: "2026-05-18T00:00:01Z".to_string(),
truncated_stdout: false,
truncated_stderr: false,
redaction_policy: "mcp_boundary_redaction_v1".to_string(),
};
let receipt_json = serde_json::to_string(&receipt).unwrap();
assert!(receipt_json.contains("\"audit_join_key\""));
assert!(receipt_json.contains("\"argv_hash\""));
assert!(receipt_json.contains("\"truncated_stdout\""));
assert!(receipt_json.contains("\"truncated_stderr\""));
assert!(receipt_json.contains("\"redaction_policy\""));
let decoded_receipt: AuthorityReceipt = serde_json::from_str(&receipt_json).unwrap();
assert_eq!(decoded_receipt, receipt);
}
#[test]
fn receipt_omits_absent_code_to_match_schema() {
let receipt = AuthorityReceipt {
receipt_id: "rcpt_123".to_string(),
run_id: "run_123".to_string(),
audit_join_key: "audit_123".to_string(),
decision: AuthorityDecision::Allow,
code: None,
command: AuthorityCommandIdentity {
display: "cordance check".to_string(),
target: "C:\\Tools\\cordance\\cordance.exe".to_string(),
argv_hash:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
},
authority: AuthorityMetadata {
profile: "profile-name".to_string(),
contract: "contract-name".to_string(),
workdir: "C:\\Users\\0ryant\\prj\\example".to_string(),
mode: AuthorityMode::BoundMcp,
},
started_at: "2026-05-18T00:00:00Z".to_string(),
finished_at: "2026-05-18T00:00:01Z".to_string(),
truncated_stdout: false,
truncated_stderr: false,
redaction_policy: "mcp_boundary_redaction_v1".to_string(),
};
let receipt_json = serde_json::to_string(&receipt).unwrap();
assert!(!receipt_json.contains("\"code\""));
let decoded_receipt: AuthorityReceipt = serde_json::from_str(&receipt_json).unwrap();
assert_eq!(decoded_receipt, receipt);
}
#[test]
fn maps_legacy_secret_denials_to_missing_required_secret() {
assert_eq!(
AuthorityDenyCode::from(DenyReason::RequiredSecretNotFound),
AuthorityDenyCode::MissingRequiredSecret
);
assert_eq!(
AuthorityDenyCode::from(DenyReason::AllowedSecretNotFound),
AuthorityDenyCode::MissingRequiredSecret
);
}
#[test]
fn maps_legacy_target_denials_to_target_denied() {
assert_eq!(
AuthorityDenyCode::from(DenyReason::TargetNotAllowed),
AuthorityDenyCode::TargetDenied
);
assert_eq!(
AuthorityDenyCode::from(DenyReason::TargetMissing),
AuthorityDenyCode::TargetDenied
);
}
#[test]
fn maps_legacy_network_denials_to_network_unenforced() {
assert_eq!(
AuthorityDenyCode::from(DenyReason::NetworkPolicyViolation),
AuthorityDenyCode::NetworkUnenforced
);
assert_eq!(
AuthorityDenyCode::from(DenyReason::NetworkUnenforced),
AuthorityDenyCode::NetworkUnenforced
);
}
}