Skip to main content

tirith_core/
approval.rs

1use std::io::Write;
2use std::path::PathBuf;
3
4use crate::policy::{ApprovalRule, Policy};
5use crate::verdict::Verdict;
6
7/// Approval metadata extracted from a verdict + policy.
8#[derive(Debug, Clone)]
9pub struct ApprovalMetadata {
10    pub requires_approval: bool,
11    pub timeout_secs: u64,
12    pub fallback: String,
13    pub rule_id: String,
14    pub description: String,
15}
16
17/// Check whether a verdict triggers any approval rules from the policy.
18///
19/// Returns `Some(ApprovalMetadata)` if approval is required, `None` otherwise.
20/// This is a Team-tier feature: callers should gate on tier before calling.
21pub fn check_approval(verdict: &Verdict, policy: &Policy) -> Option<ApprovalMetadata> {
22    if policy.approval_rules.is_empty() {
23        return None;
24    }
25
26    // Check each finding's rule_id against approval_rules
27    for finding in &verdict.findings {
28        let finding_rule_str = finding.rule_id.to_string();
29        for approval_rule in &policy.approval_rules {
30            if approval_rule_matches(&finding_rule_str, approval_rule) {
31                let description = if finding.description.is_empty() {
32                    finding.title.clone()
33                } else {
34                    finding.description.clone()
35                };
36                return Some(ApprovalMetadata {
37                    requires_approval: true,
38                    timeout_secs: approval_rule.timeout_secs,
39                    fallback: approval_rule.fallback.clone(),
40                    rule_id: finding_rule_str,
41                    description: sanitize_description(&description),
42                });
43            }
44        }
45    }
46
47    None
48}
49
50/// Apply approval metadata to a verdict (mutates in place).
51pub fn apply_approval(verdict: &mut Verdict, metadata: &ApprovalMetadata) {
52    verdict.requires_approval = Some(metadata.requires_approval);
53    verdict.approval_timeout_secs = Some(metadata.timeout_secs);
54    verdict.approval_fallback = Some(metadata.fallback.clone());
55    verdict.approval_rule = Some(metadata.rule_id.clone());
56    verdict.approval_description = Some(metadata.description.clone());
57}
58
59/// Write approval metadata to a secure temp file.
60///
61/// Returns the path to the temp file. The caller is responsible for printing
62/// this path to stdout. The temp file is persisted (not auto-deleted) so
63/// shell hooks can read it after tirith exits.
64///
65/// Per ADR-7: file is created with O_EXCL + O_CREAT (via tempfile crate),
66/// mode 0600 on Unix, and `.keep()` is called before returning.
67pub fn write_approval_file(metadata: &ApprovalMetadata) -> Result<PathBuf, std::io::Error> {
68    let mut tmp = tempfile::Builder::new()
69        .prefix("tirith-approval-")
70        .suffix(".env")
71        .tempfile()?;
72
73    // Set permissions to 0600 on Unix before writing content
74    #[cfg(unix)]
75    {
76        use std::os::unix::fs::PermissionsExt;
77        let perms = std::fs::Permissions::from_mode(0o600);
78        std::fs::set_permissions(tmp.path(), perms)?;
79    }
80
81    // Write key=value pairs
82    writeln!(
83        tmp,
84        "TIRITH_REQUIRES_APPROVAL={}",
85        if metadata.requires_approval {
86            "yes"
87        } else {
88            "no"
89        }
90    )?;
91    writeln!(tmp, "TIRITH_APPROVAL_TIMEOUT={}", metadata.timeout_secs)?;
92    writeln!(
93        tmp,
94        "TIRITH_APPROVAL_FALLBACK={}",
95        sanitize_fallback(&metadata.fallback)
96    )?;
97    writeln!(
98        tmp,
99        "TIRITH_APPROVAL_RULE={}",
100        sanitize_rule_id(&metadata.rule_id)
101    )?;
102    writeln!(
103        tmp,
104        "TIRITH_APPROVAL_DESCRIPTION={}",
105        sanitize_description(&metadata.description)
106    )?;
107
108    tmp.flush()?;
109
110    // Persist the file (prevent auto-delete on drop)
111    let (_, path) = tmp.keep().map_err(|e| e.error)?;
112    Ok(path)
113}
114
115/// Write a "no approval required" temp file for the common case.
116pub fn write_no_approval_file() -> Result<PathBuf, std::io::Error> {
117    let mut tmp = tempfile::Builder::new()
118        .prefix("tirith-approval-")
119        .suffix(".env")
120        .tempfile()?;
121
122    #[cfg(unix)]
123    {
124        use std::os::unix::fs::PermissionsExt;
125        let perms = std::fs::Permissions::from_mode(0o600);
126        std::fs::set_permissions(tmp.path(), perms)?;
127    }
128
129    writeln!(tmp, "TIRITH_REQUIRES_APPROVAL=no")?;
130    tmp.flush()?;
131
132    let (_, path) = tmp.keep().map_err(|e| e.error)?;
133    Ok(path)
134}
135
136/// Check if a finding's rule_id string matches an approval rule.
137fn approval_rule_matches(rule_id_str: &str, approval_rule: &ApprovalRule) -> bool {
138    approval_rule.rule_ids.iter().any(|r| r == rule_id_str)
139}
140
141/// Sanitize a description string per ADR-7.
142///
143/// Allowlist: `[A-Za-z0-9 .,_:/()\-']`. All other characters stripped.
144/// Consecutive spaces collapsed. Max 200 bytes, truncated with `...`.
145pub fn sanitize_description(input: &str) -> String {
146    let filtered: String = input
147        .chars()
148        .filter(|c| {
149            c.is_ascii_alphanumeric()
150                || matches!(
151                    c,
152                    ' ' | '.' | ',' | '_' | ':' | '/' | '(' | ')' | '-' | '\''
153                )
154        })
155        .collect();
156
157    // Collapse consecutive spaces
158    let mut result = String::with_capacity(filtered.len());
159    let mut prev_space = false;
160    for c in filtered.chars() {
161        if c == ' ' {
162            if !prev_space {
163                result.push(c);
164            }
165            prev_space = true;
166        } else {
167            result.push(c);
168            prev_space = false;
169        }
170    }
171
172    // Truncate to 200 bytes
173    if result.len() > 200 {
174        // Find a safe UTF-8 boundary
175        let mut end = 197;
176        while end > 0 && !result.is_char_boundary(end) {
177            end -= 1;
178        }
179        result.truncate(end);
180        result.push_str("...");
181    }
182
183    result
184}
185
186/// Sanitize the approval fallback value per ADR-7.
187///
188/// Only "block", "warn", and "allow" are valid. Any other value
189/// (including values containing newlines, `=`, or shell metacharacters)
190/// defaults to "block" for fail-closed safety.
191fn sanitize_fallback(input: &str) -> &'static str {
192    match input.trim().to_lowercase().as_str() {
193        "block" => "block",
194        "warn" => "warn",
195        "allow" => "allow",
196        _ => "block",
197    }
198}
199
200/// Sanitize a rule_id to `[a-z_]+`, max 64 chars.
201fn sanitize_rule_id(input: &str) -> String {
202    let filtered: String = input
203        .chars()
204        .filter(|c| c.is_ascii_lowercase() || *c == '_')
205        .take(64)
206        .collect();
207    filtered
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::policy::ApprovalRule;
214    use crate::verdict::{Action, Evidence, Finding, RuleId, Severity, Timings, Verdict};
215
216    fn make_verdict(rule_id: RuleId, severity: Severity) -> Verdict {
217        Verdict {
218            action: Action::Block,
219            findings: vec![Finding {
220                rule_id,
221                severity,
222                title: "Test finding".to_string(),
223                description: "A test finding description".to_string(),
224                evidence: vec![Evidence::Text {
225                    detail: "test".to_string(),
226                }],
227                human_view: None,
228                agent_view: None,
229                mitre_id: None,
230                custom_rule_id: None,
231            }],
232            tier_reached: 3,
233            bypass_requested: false,
234            bypass_honored: false,
235            interactive_detected: false,
236            policy_path_used: None,
237            timings_ms: Timings::default(),
238            urls_extracted_count: None,
239            requires_approval: None,
240            approval_timeout_secs: None,
241            approval_fallback: None,
242            approval_rule: None,
243            approval_description: None,
244        }
245    }
246
247    fn make_policy_with_approval(rule_ids: &[&str]) -> Policy {
248        let mut policy = Policy::default();
249        policy.approval_rules.push(ApprovalRule {
250            rule_ids: rule_ids.iter().map(|s| s.to_string()).collect(),
251            timeout_secs: 30,
252            fallback: "block".to_string(),
253        });
254        policy
255    }
256
257    #[test]
258    fn test_check_approval_matches() {
259        let verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
260        let policy = make_policy_with_approval(&["curl_pipe_shell"]);
261
262        let meta = check_approval(&verdict, &policy);
263        assert!(meta.is_some());
264        let meta = meta.unwrap();
265        assert!(meta.requires_approval);
266        assert_eq!(meta.timeout_secs, 30);
267        assert_eq!(meta.fallback, "block");
268        assert_eq!(meta.rule_id, "curl_pipe_shell");
269    }
270
271    #[test]
272    fn test_check_approval_no_match() {
273        let verdict = make_verdict(RuleId::NonAsciiHostname, Severity::Medium);
274        let policy = make_policy_with_approval(&["curl_pipe_shell"]);
275
276        let meta = check_approval(&verdict, &policy);
277        assert!(meta.is_none());
278    }
279
280    #[test]
281    fn test_check_approval_empty_rules() {
282        let verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
283        let policy = Policy::default(); // no approval_rules
284
285        let meta = check_approval(&verdict, &policy);
286        assert!(meta.is_none());
287    }
288
289    #[test]
290    fn test_sanitize_description_basic() {
291        assert_eq!(
292            sanitize_description("Normal text with (parens) and 123"),
293            "Normal text with (parens) and 123"
294        );
295    }
296
297    #[test]
298    fn test_sanitize_description_strips_dangerous() {
299        assert_eq!(
300            sanitize_description("echo $HOME; rm -rf /; `whoami`"),
301            "echo HOME rm -rf / whoami"
302        );
303    }
304
305    #[test]
306    fn test_sanitize_description_collapses_spaces() {
307        assert_eq!(
308            sanitize_description("too   many    spaces"),
309            "too many spaces"
310        );
311    }
312
313    #[test]
314    fn test_sanitize_description_truncates() {
315        let long = "a".repeat(300);
316        let result = sanitize_description(&long);
317        assert!(result.len() <= 200);
318        assert!(result.ends_with("..."));
319    }
320
321    #[test]
322    fn test_sanitize_rule_id() {
323        // Normal snake_case (from serde serialization) passes through
324        assert_eq!(sanitize_rule_id("curl_pipe_shell"), "curl_pipe_shell");
325        // Uppercase letters are stripped (only [a-z_] allowed)
326        assert_eq!(sanitize_rule_id("CurlPipeShell"), "urlipehell");
327        // Truncates to 64 chars
328        assert_eq!(sanitize_rule_id(&"a".repeat(100)), "a".repeat(64));
329    }
330
331    #[test]
332    fn test_sanitize_fallback() {
333        assert_eq!(sanitize_fallback("block"), "block");
334        assert_eq!(sanitize_fallback("warn"), "warn");
335        assert_eq!(sanitize_fallback("allow"), "allow");
336        assert_eq!(sanitize_fallback("BLOCK"), "block");
337        assert_eq!(sanitize_fallback("  warn  "), "warn");
338        // Malicious values default to "block"
339        assert_eq!(sanitize_fallback("block\nINJECTED=yes"), "block");
340        assert_eq!(
341            sanitize_fallback("allow\r\nTIRITH_REQUIRES_APPROVAL=no"),
342            "block"
343        );
344        assert_eq!(sanitize_fallback(""), "block");
345        assert_eq!(sanitize_fallback("invalid"), "block");
346    }
347
348    #[test]
349    fn test_apply_approval() {
350        let mut verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
351        let meta = ApprovalMetadata {
352            requires_approval: true,
353            timeout_secs: 60,
354            fallback: "warn".to_string(),
355            rule_id: "curl_pipe_shell".to_string(),
356            description: "Pipe to shell detected".to_string(),
357        };
358        apply_approval(&mut verdict, &meta);
359
360        assert_eq!(verdict.requires_approval, Some(true));
361        assert_eq!(verdict.approval_timeout_secs, Some(60));
362        assert_eq!(verdict.approval_fallback.as_deref(), Some("warn"));
363        assert_eq!(verdict.approval_rule.as_deref(), Some("curl_pipe_shell"));
364    }
365
366    #[test]
367    fn test_write_approval_file() {
368        let meta = ApprovalMetadata {
369            requires_approval: true,
370            timeout_secs: 30,
371            fallback: "block".to_string(),
372            rule_id: "curl_pipe_shell".to_string(),
373            description: "Pipe to shell detected".to_string(),
374        };
375
376        let path = write_approval_file(&meta).expect("write should succeed");
377        assert!(path.exists());
378
379        let content = std::fs::read_to_string(&path).expect("read should succeed");
380        assert!(content.contains("TIRITH_REQUIRES_APPROVAL=yes"));
381        assert!(content.contains("TIRITH_APPROVAL_TIMEOUT=30"));
382        assert!(content.contains("TIRITH_APPROVAL_FALLBACK=block"));
383        assert!(content.contains("TIRITH_APPROVAL_RULE=curl_pipe_shell"));
384        assert!(content.contains("TIRITH_APPROVAL_DESCRIPTION=Pipe to shell detected"));
385
386        // Verify file permissions on Unix
387        #[cfg(unix)]
388        {
389            use std::os::unix::fs::PermissionsExt;
390            let perms = std::fs::metadata(&path).unwrap().permissions();
391            assert_eq!(perms.mode() & 0o777, 0o600);
392        }
393
394        // Cleanup
395        let _ = std::fs::remove_file(&path);
396    }
397
398    #[test]
399    fn test_write_no_approval_file() {
400        let path = write_no_approval_file().expect("write should succeed");
401        assert!(path.exists());
402
403        let content = std::fs::read_to_string(&path).expect("read should succeed");
404        assert!(content.contains("TIRITH_REQUIRES_APPROVAL=no"));
405        assert!(!content.contains("TIRITH_APPROVAL_TIMEOUT"));
406
407        let _ = std::fs::remove_file(&path);
408    }
409}