Skip to main content

tsafe_core/
authority.rs

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}