Skip to main content

tsafe_core/
compliance_narrative.rs

1//! Compliance narrative format for exec audit explanation.
2//!
3//! Formats audit operations into a three-question narrative structure:
4//! 1. "granted:" — What authority was granted to this command?
5//! 2. "target:" — How was the target command evaluated and resolved?
6//! 3. "denied/stripped:" — What was denied or stripped, and why?
7//!
8//! This format is designed for compliance review and operator diagnosis without
9//! leaking plaintext secret values.
10
11use serde::{Deserialize, Serialize};
12
13use crate::audit::{AuditExecContext, AuditStatus};
14use crate::audit_explain::ExecutionAuthoritySummary;
15
16/// Compliance narrative explaining an exec operation in three questions.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ComplianceNarrative {
19    /// What authority was granted to this command?
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub granted: Option<GrantedAuthority>,
22
23    /// How was the target command evaluated and resolved?
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub target: Option<TargetDecisionChain>,
26
27    /// What was denied or stripped, and why?
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub denied_stripped: Option<DeniedAndStripped>,
30}
31
32/// Q1: What authority was granted?
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct GrantedAuthority {
35    /// The contract name (if provided) or a summary of the mode/policy
36    pub policy_source: String,
37
38    /// Number of secrets granted (by name hash ref)
39    pub secret_count: usize,
40
41    /// Trust level applied (standard / hardened / custom)
42    pub trust_level: String,
43
44    /// Environment inheritance mode (full / minimal / custom)
45    pub inherit_mode: String,
46
47    /// Whether output redaction is active
48    pub output_redacted: bool,
49}
50
51/// Q2: How was the target evaluated?
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct TargetDecisionChain {
54    /// The command that was attempted
55    pub command: String,
56
57    /// The decision code (UNCONSTRAINED, ALLOWED_EXACT, ALLOWED_BASENAME, MISSING, DENIED)
58    pub decision_code: String,
59
60    /// Human-readable explanation of the decision
61    pub decision_explanation: String,
62
63    /// If denied, the reason code and message
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub denial_reason: Option<String>,
66}
67
68/// Q3: What was denied or stripped?
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct DeniedAndStripped {
71    /// Environment variables stripped (those not in --only or --inherit policy)
72    pub stripped_env_names: Vec<String>,
73
74    /// Secrets that were blocked (in --allowed but not injected)
75    pub blocked_secrets: Vec<String>,
76
77    /// Top-level deny reason if the operation failed
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub operation_denied_reason: Option<String>,
80
81    /// Access profile enforcement notes (e.g. read-only violations)
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub access_profile_notes: Option<String>,
84}
85
86impl ComplianceNarrative {
87    /// Create a compliance narrative from exec context and summary.
88    pub fn from_exec_context(
89        ctx: &AuditExecContext,
90        _summary: Option<&ExecutionAuthoritySummary>,
91        _status: &AuditStatus,
92    ) -> Self {
93        let granted = ctx.trust_level.as_ref().map(|trust| GrantedAuthority {
94            policy_source: ctx
95                .contract_name
96                .clone()
97                .unwrap_or_else(|| "explicit flags".to_string()),
98            secret_count: ctx.injected_secrets.len(),
99            trust_level: trust.as_str().to_string(),
100            inherit_mode: ctx
101                .inherit
102                .map(|m| format!("{m:?}").to_lowercase())
103                .unwrap_or_else(|| "unknown".to_string()),
104            output_redacted: ctx.redact_output.unwrap_or(false),
105        });
106
107        let target = ctx.target.as_ref().map(|cmd| {
108            let (decision_code, decision_explanation) = match ctx.target_decision {
109                Some(td) => (format!("{td:?}"), format!("{td:?}")),
110                None => (
111                    "UNKNOWN".to_string(),
112                    "no target decision recorded".to_string(),
113                ),
114            };
115
116            let denial_reason = ctx
117                .deny_reason
118                .map(|r| format!("{}: {}", r.code(), r.message()));
119
120            TargetDecisionChain {
121                command: cmd.clone(),
122                decision_code,
123                decision_explanation,
124                denial_reason,
125            }
126        });
127
128        let denied_stripped = Some(DeniedAndStripped {
129            stripped_env_names: ctx.dropped_env_names.clone(),
130            blocked_secrets: ctx
131                .allowed_secrets
132                .iter()
133                .filter(|s| !ctx.injected_secrets.contains(s))
134                .cloned()
135                .collect(),
136            operation_denied_reason: ctx
137                .deny_reason
138                .map(|r| format!("{}: {}", r.code(), r.message())),
139            access_profile_notes: None,
140        });
141
142        Self {
143            granted,
144            target,
145            denied_stripped,
146        }
147    }
148
149    /// Format as plaintext for human review.
150    pub fn to_plaintext(&self) -> String {
151        let mut output = String::new();
152
153        // Q1: Granted
154        if let Some(grant) = &self.granted {
155            output.push_str("granted:\n");
156            output.push_str(&format!("  policy_source: {}\n", grant.policy_source));
157            output.push_str(&format!("  secret_count: {}\n", grant.secret_count));
158            output.push_str(&format!("  trust_level: {}\n", grant.trust_level));
159            output.push_str(&format!("  inherit_mode: {}\n", grant.inherit_mode));
160            output.push_str(&format!("  output_redacted: {}\n", grant.output_redacted));
161        }
162
163        // Q2: Target
164        if let Some(tgt) = &self.target {
165            output.push_str("target:\n");
166            output.push_str(&format!("  command: {}\n", tgt.command));
167            output.push_str(&format!("  decision: {}\n", tgt.decision_code));
168            if let Some(reason) = &tgt.denial_reason {
169                output.push_str(&format!("  denial_reason: {reason}\n"));
170            }
171        }
172
173        // Q3: Denied/Stripped
174        if let Some(ds) = &self.denied_stripped {
175            output.push_str("denied_stripped:\n");
176            if !ds.stripped_env_names.is_empty() {
177                output.push_str(&format!(
178                    "  stripped_env: {}\n",
179                    ds.stripped_env_names.join(", ")
180                ));
181            }
182            if !ds.blocked_secrets.is_empty() {
183                output.push_str(&format!(
184                    "  blocked_secrets: {}\n",
185                    ds.blocked_secrets.len()
186                ));
187            }
188            if let Some(reason) = &ds.operation_denied_reason {
189                output.push_str(&format!("  denial_reason: {reason}\n"));
190            }
191        }
192
193        output
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn compliance_narrative_plaintext_format_is_readable() {
203        let narrative = ComplianceNarrative {
204            granted: Some(GrantedAuthority {
205                policy_source: "deploy".to_string(),
206                secret_count: 2,
207                trust_level: "hardened".to_string(),
208                inherit_mode: "minimal".to_string(),
209                output_redacted: true,
210            }),
211            target: Some(TargetDecisionChain {
212                command: "terraform".to_string(),
213                decision_code: "AllowedExact".to_string(),
214                decision_explanation: "matched allowlist by exact name".to_string(),
215                denial_reason: None,
216            }),
217            denied_stripped: Some(DeniedAndStripped {
218                stripped_env_names: vec!["HOME".to_string(), "PATH".to_string()],
219                blocked_secrets: vec![],
220                operation_denied_reason: None,
221                access_profile_notes: None,
222            }),
223        };
224
225        let text = narrative.to_plaintext();
226        assert!(text.contains("granted:"));
227        assert!(text.contains("policy_source: deploy"));
228        assert!(text.contains("target:"));
229        assert!(text.contains("command: terraform"));
230        assert!(text.contains("denied_stripped:"));
231        assert!(text.contains("stripped_env: HOME, PATH"));
232    }
233}