1use serde::{Deserialize, Serialize};
2
3use crate::deny_reason::DenyReason;
4
5#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6#[serde(rename_all = "snake_case")]
7pub enum AuthorityDecision {
8 Allow,
9 Deny,
10 Diagnose,
11}
12
13#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum AuthorityDenyCode {
16 MissingContract,
17 LockedVault,
18 MissingAgent,
19 MissingRequiredSecret,
20 BadWorkdir,
21 TargetDenied,
22 PathEscape,
23 BlankScope,
24 ProfileOverride,
25 ContractOverride,
26 RequestTimeWidening,
27 NetworkUnenforced,
28 AuditUnavailable,
29 OutputCap,
30 Timeout,
31 HostSchemaUnstable,
32 ConfigStale,
33 ProofUnavailable,
34 ParseError,
35 InternalError,
36}
37
38impl From<DenyReason> for AuthorityDenyCode {
39 fn from(reason: DenyReason) -> Self {
40 match reason {
41 DenyReason::TargetNotAllowed | DenyReason::TargetMissing => Self::TargetDenied,
42 DenyReason::RequiredSecretNotFound | DenyReason::AllowedSecretNotFound => {
43 Self::MissingRequiredSecret
44 }
45 DenyReason::DangerousEnvVariable => Self::RequestTimeWidening,
46 DenyReason::VaultProfileNotFound
47 | DenyReason::NamespaceMissing
48 | DenyReason::AccessProfileViolation => Self::MissingRequiredSecret,
49 DenyReason::NetworkPolicyViolation | DenyReason::NetworkUnenforced => {
50 Self::NetworkUnenforced
51 }
52 DenyReason::InsufficientAuthority => Self::BlankScope,
53 DenyReason::MissingContract => Self::MissingContract,
54 DenyReason::LockedVault => Self::LockedVault,
55 DenyReason::MissingAgent => Self::MissingAgent,
56 DenyReason::BadWorkdir => Self::BadWorkdir,
57 DenyReason::PathEscape => Self::PathEscape,
58 DenyReason::BlankScope => Self::BlankScope,
59 DenyReason::ProfileOverride => Self::ProfileOverride,
60 DenyReason::ContractOverride => Self::ContractOverride,
61 DenyReason::RequestTimeWidening => Self::RequestTimeWidening,
62 DenyReason::AuditUnavailable => Self::AuditUnavailable,
63 DenyReason::OutputCap => Self::OutputCap,
64 DenyReason::Timeout => Self::Timeout,
65 DenyReason::HostSchemaUnstable => Self::HostSchemaUnstable,
66 DenyReason::ConfigStale => Self::ConfigStale,
67 DenyReason::ProofUnavailable => Self::ProofUnavailable,
68 DenyReason::ParseError => Self::ParseError,
69 DenyReason::InternalError => Self::InternalError,
70 }
71 }
72}
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum AuthorityMode {
77 BoundMcp,
78}
79
80#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub struct AuthorityMetadata {
82 pub profile: String,
83 pub contract: String,
84 pub workdir: String,
85 pub mode: AuthorityMode,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89pub struct AuthorityRefusal {
90 pub summary: String,
91 pub detail: String,
92 pub next_actions: Vec<String>,
93 pub code: AuthorityDenyCode,
94 pub authority: AuthorityMetadata,
95 pub receipt_id: String,
96}
97
98impl AuthorityRefusal {
99 pub fn new(
100 summary: impl Into<String>,
101 detail: impl Into<String>,
102 next_actions: Vec<String>,
103 code: AuthorityDenyCode,
104 authority: AuthorityMetadata,
105 receipt_id: impl Into<String>,
106 ) -> Self {
107 Self {
108 summary: summary.into(),
109 detail: detail.into(),
110 next_actions,
111 code,
112 authority,
113 receipt_id: receipt_id.into(),
114 }
115 }
116}
117
118#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
119pub struct AuthorityCommandIdentity {
120 pub display: String,
121 pub target: String,
122 pub argv_hash: String,
123}
124
125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
126pub struct AuthorityReceipt {
127 pub receipt_id: String,
128 pub run_id: String,
129 pub audit_join_key: String,
130 pub decision: AuthorityDecision,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub code: Option<AuthorityDenyCode>,
133 pub command: AuthorityCommandIdentity,
134 pub authority: AuthorityMetadata,
135 pub started_at: String,
136 pub finished_at: String,
137 pub truncated_stdout: bool,
138 pub truncated_stderr: bool,
139 pub redaction_policy: String,
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn serializes_authority_decision_code_and_mode_as_snake_case() {
148 assert_eq!(
149 serde_json::to_string(&AuthorityDecision::Allow).unwrap(),
150 "\"allow\""
151 );
152 assert_eq!(
153 serde_json::to_string(&AuthorityDecision::Deny).unwrap(),
154 "\"deny\""
155 );
156 assert_eq!(
157 serde_json::to_string(&AuthorityDecision::Diagnose).unwrap(),
158 "\"diagnose\""
159 );
160 assert_eq!(
161 serde_json::to_string(&AuthorityDenyCode::MissingContract).unwrap(),
162 "\"missing_contract\""
163 );
164 assert_eq!(
165 serde_json::to_string(&AuthorityDenyCode::LockedVault).unwrap(),
166 "\"locked_vault\""
167 );
168 assert_eq!(
169 serde_json::to_string(&AuthorityDenyCode::MissingAgent).unwrap(),
170 "\"missing_agent\""
171 );
172 assert_eq!(
173 serde_json::to_string(&AuthorityDenyCode::MissingRequiredSecret).unwrap(),
174 "\"missing_required_secret\""
175 );
176 assert_eq!(
177 serde_json::to_string(&AuthorityDenyCode::BadWorkdir).unwrap(),
178 "\"bad_workdir\""
179 );
180 assert_eq!(
181 serde_json::to_string(&AuthorityDenyCode::TargetDenied).unwrap(),
182 "\"target_denied\""
183 );
184 assert_eq!(
185 serde_json::to_string(&AuthorityDenyCode::PathEscape).unwrap(),
186 "\"path_escape\""
187 );
188 assert_eq!(
189 serde_json::to_string(&AuthorityDenyCode::BlankScope).unwrap(),
190 "\"blank_scope\""
191 );
192 assert_eq!(
193 serde_json::to_string(&AuthorityDenyCode::ProfileOverride).unwrap(),
194 "\"profile_override\""
195 );
196 assert_eq!(
197 serde_json::to_string(&AuthorityDenyCode::ContractOverride).unwrap(),
198 "\"contract_override\""
199 );
200 assert_eq!(
201 serde_json::to_string(&AuthorityDenyCode::RequestTimeWidening).unwrap(),
202 "\"request_time_widening\""
203 );
204 assert_eq!(
205 serde_json::to_string(&AuthorityDenyCode::NetworkUnenforced).unwrap(),
206 "\"network_unenforced\""
207 );
208 assert_eq!(
209 serde_json::to_string(&AuthorityDenyCode::AuditUnavailable).unwrap(),
210 "\"audit_unavailable\""
211 );
212 assert_eq!(
213 serde_json::to_string(&AuthorityDenyCode::OutputCap).unwrap(),
214 "\"output_cap\""
215 );
216 assert_eq!(
217 serde_json::to_string(&AuthorityDenyCode::Timeout).unwrap(),
218 "\"timeout\""
219 );
220 assert_eq!(
221 serde_json::to_string(&AuthorityDenyCode::HostSchemaUnstable).unwrap(),
222 "\"host_schema_unstable\""
223 );
224 assert_eq!(
225 serde_json::to_string(&AuthorityDenyCode::ConfigStale).unwrap(),
226 "\"config_stale\""
227 );
228 assert_eq!(
229 serde_json::to_string(&AuthorityDenyCode::ProofUnavailable).unwrap(),
230 "\"proof_unavailable\""
231 );
232 assert_eq!(
233 serde_json::to_string(&AuthorityDenyCode::ParseError).unwrap(),
234 "\"parse_error\""
235 );
236 assert_eq!(
237 serde_json::to_string(&AuthorityDenyCode::InternalError).unwrap(),
238 "\"internal_error\""
239 );
240 assert_eq!(
241 serde_json::to_string(&AuthorityMode::BoundMcp).unwrap(),
242 "\"bound_mcp\""
243 );
244 }
245
246 #[test]
247 fn refusal_and_receipt_roundtrip_with_snake_case_fields() {
248 let authority = AuthorityMetadata {
249 profile: "profile-name".to_string(),
250 contract: "contract-name".to_string(),
251 workdir: "C:\\Users\\0ryant\\prj\\example".to_string(),
252 mode: AuthorityMode::BoundMcp,
253 };
254 let refusal = AuthorityRefusal::new(
255 "Short model-safe refusal sentence.",
256 "Operator-actionable explanation without secret values.",
257 vec!["Concrete remediation step.".to_string()],
258 AuthorityDenyCode::TargetDenied,
259 authority.clone(),
260 "rcpt_123",
261 );
262
263 let refusal_json = serde_json::to_string(&refusal).unwrap();
264 assert!(refusal_json.contains("\"next_actions\""));
265 assert!(refusal_json.contains("\"receipt_id\""));
266 assert!(refusal_json.contains("\"target_denied\""));
267 let decoded_refusal: AuthorityRefusal = serde_json::from_str(&refusal_json).unwrap();
268 assert_eq!(decoded_refusal, refusal);
269
270 let receipt = AuthorityReceipt {
271 receipt_id: "rcpt_123".to_string(),
272 run_id: "run_123".to_string(),
273 audit_join_key: "audit_123".to_string(),
274 decision: AuthorityDecision::Deny,
275 code: Some(AuthorityDenyCode::TargetDenied),
276 command: AuthorityCommandIdentity {
277 display: "cordance check".to_string(),
278 target: "C:\\Tools\\cordance\\cordance.exe".to_string(),
279 argv_hash:
280 "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
281 .to_string(),
282 },
283 authority,
284 started_at: "2026-05-18T00:00:00Z".to_string(),
285 finished_at: "2026-05-18T00:00:01Z".to_string(),
286 truncated_stdout: false,
287 truncated_stderr: false,
288 redaction_policy: "mcp_boundary_redaction_v1".to_string(),
289 };
290
291 let receipt_json = serde_json::to_string(&receipt).unwrap();
292 assert!(receipt_json.contains("\"audit_join_key\""));
293 assert!(receipt_json.contains("\"argv_hash\""));
294 assert!(receipt_json.contains("\"truncated_stdout\""));
295 assert!(receipt_json.contains("\"truncated_stderr\""));
296 assert!(receipt_json.contains("\"redaction_policy\""));
297 let decoded_receipt: AuthorityReceipt = serde_json::from_str(&receipt_json).unwrap();
298 assert_eq!(decoded_receipt, receipt);
299 }
300
301 #[test]
302 fn receipt_omits_absent_code_to_match_schema() {
303 let receipt = AuthorityReceipt {
304 receipt_id: "rcpt_123".to_string(),
305 run_id: "run_123".to_string(),
306 audit_join_key: "audit_123".to_string(),
307 decision: AuthorityDecision::Allow,
308 code: None,
309 command: AuthorityCommandIdentity {
310 display: "cordance check".to_string(),
311 target: "C:\\Tools\\cordance\\cordance.exe".to_string(),
312 argv_hash:
313 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
314 .to_string(),
315 },
316 authority: AuthorityMetadata {
317 profile: "profile-name".to_string(),
318 contract: "contract-name".to_string(),
319 workdir: "C:\\Users\\0ryant\\prj\\example".to_string(),
320 mode: AuthorityMode::BoundMcp,
321 },
322 started_at: "2026-05-18T00:00:00Z".to_string(),
323 finished_at: "2026-05-18T00:00:01Z".to_string(),
324 truncated_stdout: false,
325 truncated_stderr: false,
326 redaction_policy: "mcp_boundary_redaction_v1".to_string(),
327 };
328
329 let receipt_json = serde_json::to_string(&receipt).unwrap();
330 assert!(!receipt_json.contains("\"code\""));
331 let decoded_receipt: AuthorityReceipt = serde_json::from_str(&receipt_json).unwrap();
332 assert_eq!(decoded_receipt, receipt);
333 }
334
335 #[test]
336 fn maps_legacy_secret_denials_to_missing_required_secret() {
337 assert_eq!(
338 AuthorityDenyCode::from(DenyReason::RequiredSecretNotFound),
339 AuthorityDenyCode::MissingRequiredSecret
340 );
341 assert_eq!(
342 AuthorityDenyCode::from(DenyReason::AllowedSecretNotFound),
343 AuthorityDenyCode::MissingRequiredSecret
344 );
345 }
346
347 #[test]
348 fn maps_legacy_target_denials_to_target_denied() {
349 assert_eq!(
350 AuthorityDenyCode::from(DenyReason::TargetNotAllowed),
351 AuthorityDenyCode::TargetDenied
352 );
353 assert_eq!(
354 AuthorityDenyCode::from(DenyReason::TargetMissing),
355 AuthorityDenyCode::TargetDenied
356 );
357 }
358
359 #[test]
360 fn maps_legacy_network_denials_to_network_unenforced() {
361 assert_eq!(
362 AuthorityDenyCode::from(DenyReason::NetworkPolicyViolation),
363 AuthorityDenyCode::NetworkUnenforced
364 );
365 assert_eq!(
366 AuthorityDenyCode::from(DenyReason::NetworkUnenforced),
367 AuthorityDenyCode::NetworkUnenforced
368 );
369 }
370}