Skip to main content

tirith_core/
approval.rs

1use std::io::Write;
2use std::path::PathBuf;
3use std::time::{Duration, SystemTime};
4
5use crate::policy::{ApprovalRule, Policy};
6use crate::verdict::Verdict;
7
8/// Approval/warn-ack temp files older than this are considered abandoned
9/// (e.g. a `tirith check --approval-check` invoked from a terminal without
10/// a hook on the receiving end) and removed opportunistically on the next
11/// write. A live hook normally reads + deletes its own file within seconds,
12/// so an hour is a safe bound that won't race.
13const STALE_APPROVAL_TTL: Duration = Duration::from_secs(3600);
14
15/// Best-effort cleanup of leaked approval/warn-ack temp files in `$TEMP`.
16/// Called before each fresh write so a leak from a prior CLI test doesn't
17/// accumulate forever. Errors are silently ignored — this is housekeeping,
18/// not a hard requirement.
19fn cleanup_stale_temp_files() {
20    let dir = std::env::temp_dir();
21    let now = SystemTime::now();
22    let entries = match std::fs::read_dir(&dir) {
23        Ok(e) => e,
24        Err(_) => return,
25    };
26    for entry in entries.flatten() {
27        let path = entry.path();
28        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
29            continue;
30        };
31        if !name.ends_with(".env") {
32            continue;
33        }
34        if !(name.starts_with("tirith-approval-") || name.starts_with("tirith-warnack-")) {
35            continue;
36        }
37        let Ok(meta) = entry.metadata() else { continue };
38        let Ok(modified) = meta.modified() else {
39            continue;
40        };
41        let Ok(age) = now.duration_since(modified) else {
42            continue;
43        };
44        if age > STALE_APPROVAL_TTL {
45            let _ = std::fs::remove_file(&path);
46        }
47    }
48}
49
50/// Approval metadata extracted from a verdict + policy.
51#[derive(Debug, Clone)]
52pub struct ApprovalMetadata {
53    pub requires_approval: bool,
54    pub timeout_secs: u64,
55    pub fallback: String,
56    pub rule_id: String,
57    pub description: String,
58}
59
60/// Check whether a verdict triggers any approval rules from the policy.
61///
62/// Returns `Some(ApprovalMetadata)` if approval is required, `None` otherwise.
63/// This is a Team-tier feature: callers should gate on tier before calling.
64pub fn check_approval(verdict: &Verdict, policy: &Policy) -> Option<ApprovalMetadata> {
65    if policy.approval_rules.is_empty() {
66        return None;
67    }
68
69    for finding in &verdict.findings {
70        let finding_rule_str = finding.rule_id.to_string();
71        for approval_rule in &policy.approval_rules {
72            if approval_rule_matches(&finding_rule_str, approval_rule) {
73                let description = if finding.description.is_empty() {
74                    finding.title.clone()
75                } else {
76                    finding.description.clone()
77                };
78                return Some(ApprovalMetadata {
79                    requires_approval: true,
80                    timeout_secs: approval_rule.timeout_secs,
81                    fallback: approval_rule.fallback.clone(),
82                    rule_id: finding_rule_str,
83                    description: sanitize_description(&description),
84                });
85            }
86        }
87    }
88
89    None
90}
91
92/// Apply approval metadata to a verdict (mutates in place).
93pub fn apply_approval(verdict: &mut Verdict, metadata: &ApprovalMetadata) {
94    verdict.requires_approval = Some(metadata.requires_approval);
95    verdict.approval_timeout_secs = Some(metadata.timeout_secs);
96    verdict.approval_fallback = Some(metadata.fallback.clone());
97    verdict.approval_rule = Some(metadata.rule_id.clone());
98    verdict.approval_description = Some(metadata.description.clone());
99}
100
101/// Write approval metadata to a secure temp file.
102///
103/// Returns the path to the temp file. The caller is responsible for printing
104/// this path to stdout. The temp file is persisted (not auto-deleted) so
105/// shell hooks can read it after tirith exits.
106///
107/// Per ADR-7: file is created with O_EXCL + O_CREAT (via tempfile crate),
108/// mode 0600 on Unix, and `.keep()` is called before returning.
109pub fn write_approval_file(metadata: &ApprovalMetadata) -> Result<PathBuf, std::io::Error> {
110    cleanup_stale_temp_files();
111    let mut tmp = tempfile::Builder::new()
112        .prefix("tirith-approval-")
113        .suffix(".env")
114        .tempfile()?;
115
116    #[cfg(unix)]
117    {
118        use std::os::unix::fs::PermissionsExt;
119        let perms = std::fs::Permissions::from_mode(0o600);
120        std::fs::set_permissions(tmp.path(), perms)?;
121    }
122
123    writeln!(
124        tmp,
125        "TIRITH_REQUIRES_APPROVAL={}",
126        if metadata.requires_approval {
127            "yes"
128        } else {
129            "no"
130        }
131    )?;
132    writeln!(tmp, "TIRITH_APPROVAL_TIMEOUT={}", metadata.timeout_secs)?;
133    writeln!(
134        tmp,
135        "TIRITH_APPROVAL_FALLBACK={}",
136        sanitize_fallback(&metadata.fallback)
137    )?;
138    writeln!(
139        tmp,
140        "TIRITH_APPROVAL_RULE={}",
141        sanitize_rule_id(&metadata.rule_id)
142    )?;
143    writeln!(
144        tmp,
145        "TIRITH_APPROVAL_DESCRIPTION={}",
146        sanitize_description(&metadata.description)
147    )?;
148
149    tmp.flush()?;
150
151    // `.keep()` prevents auto-delete on drop so shell hooks can read the file after tirith exits.
152    let (_, path) = tmp.keep().map_err(|e| e.error)?;
153    Ok(path)
154}
155
156/// Write a "no approval required" temp file for the common case.
157pub fn write_no_approval_file() -> Result<PathBuf, std::io::Error> {
158    cleanup_stale_temp_files();
159    let mut tmp = tempfile::Builder::new()
160        .prefix("tirith-approval-")
161        .suffix(".env")
162        .tempfile()?;
163
164    #[cfg(unix)]
165    {
166        use std::os::unix::fs::PermissionsExt;
167        let perms = std::fs::Permissions::from_mode(0o600);
168        std::fs::set_permissions(tmp.path(), perms)?;
169    }
170
171    writeln!(tmp, "TIRITH_REQUIRES_APPROVAL=no")?;
172    tmp.flush()?;
173
174    let (_, path) = tmp.keep().map_err(|e| e.error)?;
175    Ok(path)
176}
177
178/// Write warn-ack metadata to a secure temp file for hook-driven strict_warn.
179///
180/// The shell hook reads this file to know how many warnings need acknowledgement
181/// and the maximum severity. Follows the same security pattern as
182/// `write_approval_file()`: O_EXCL + O_CREAT, mode 0600, `.keep()` before return.
183pub fn write_warn_ack_file(
184    finding_count: usize,
185    max_severity: &crate::verdict::Severity,
186) -> Result<PathBuf, std::io::Error> {
187    cleanup_stale_temp_files();
188    let mut tmp = tempfile::Builder::new()
189        .prefix("tirith-warnack-")
190        .suffix(".env")
191        .tempfile()?;
192
193    #[cfg(unix)]
194    {
195        use std::os::unix::fs::PermissionsExt;
196        let perms = std::fs::Permissions::from_mode(0o600);
197        std::fs::set_permissions(tmp.path(), perms)?;
198    }
199
200    writeln!(tmp, "TIRITH_WARN_ACK_REQUIRED=yes")?;
201    writeln!(tmp, "TIRITH_WARN_ACK_FINDINGS={finding_count}")?;
202    writeln!(tmp, "TIRITH_WARN_ACK_MAX_SEVERITY={max_severity}")?;
203
204    tmp.flush()?;
205
206    let (_, path) = tmp.keep().map_err(|e| e.error)?;
207    Ok(path)
208}
209
210/// Check if a finding's rule_id string matches an approval rule.
211fn approval_rule_matches(rule_id_str: &str, approval_rule: &ApprovalRule) -> bool {
212    approval_rule.rule_ids.iter().any(|r| r == rule_id_str)
213}
214
215/// Sanitize a description string per ADR-7.
216///
217/// Allowlist: `[A-Za-z0-9 .,_:/()\-']`. All other characters stripped.
218/// Consecutive spaces collapsed. Max 200 bytes, truncated with `...`.
219pub fn sanitize_description(input: &str) -> String {
220    let filtered: String = input
221        .chars()
222        .filter(|c| {
223            c.is_ascii_alphanumeric()
224                || matches!(
225                    c,
226                    ' ' | '.' | ',' | '_' | ':' | '/' | '(' | ')' | '-' | '\''
227                )
228        })
229        .collect();
230
231    // Collapse consecutive spaces
232    let mut result = String::with_capacity(filtered.len());
233    let mut prev_space = false;
234    for c in filtered.chars() {
235        if c == ' ' {
236            if !prev_space {
237                result.push(c);
238            }
239            prev_space = true;
240        } else {
241            result.push(c);
242            prev_space = false;
243        }
244    }
245
246    // Truncate to 200 bytes
247    if result.len() > 200 {
248        // Find a safe UTF-8 boundary
249        let mut end = 197;
250        while end > 0 && !result.is_char_boundary(end) {
251            end -= 1;
252        }
253        result.truncate(end);
254        result.push_str("...");
255    }
256
257    result
258}
259
260/// Sanitize the approval fallback value per ADR-7.
261///
262/// Only "block", "warn", and "allow" are valid. Any other value
263/// (including values containing newlines, `=`, or shell metacharacters)
264/// defaults to "block" for fail-closed safety.
265fn sanitize_fallback(input: &str) -> &'static str {
266    match input.trim().to_lowercase().as_str() {
267        "block" => "block",
268        "warn" => "warn",
269        "allow" => "allow",
270        _ => "block",
271    }
272}
273
274/// Sanitize a rule_id to `[a-z_]+`, max 64 chars.
275fn sanitize_rule_id(input: &str) -> String {
276    let filtered: String = input
277        .chars()
278        .filter(|c| c.is_ascii_lowercase() || *c == '_')
279        .take(64)
280        .collect();
281    filtered
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::policy::ApprovalRule;
288    use crate::verdict::{Action, Evidence, Finding, RuleId, Severity, Timings, Verdict};
289
290    fn make_verdict(rule_id: RuleId, severity: Severity) -> Verdict {
291        Verdict {
292            action: Action::Block,
293            findings: vec![Finding {
294                rule_id,
295                severity,
296                title: "Test finding".to_string(),
297                description: "A test finding description".to_string(),
298                evidence: vec![Evidence::Text {
299                    detail: "test".to_string(),
300                }],
301                human_view: None,
302                agent_view: None,
303                mitre_id: None,
304                custom_rule_id: None,
305            }],
306            tier_reached: 3,
307            bypass_requested: false,
308            bypass_honored: false,
309            bypass_available: false,
310            interactive_detected: false,
311            policy_path_used: None,
312            timings_ms: Timings::default(),
313            urls_extracted_count: None,
314            requires_approval: None,
315            approval_timeout_secs: None,
316            approval_fallback: None,
317            approval_rule: None,
318            approval_description: None,
319            escalation_reason: None,
320        }
321    }
322
323    fn make_policy_with_approval(rule_ids: &[&str]) -> Policy {
324        let mut policy = Policy::default();
325        policy.approval_rules.push(ApprovalRule {
326            rule_ids: rule_ids.iter().map(|s| s.to_string()).collect(),
327            timeout_secs: 30,
328            fallback: "block".to_string(),
329        });
330        policy
331    }
332
333    #[test]
334    fn test_check_approval_matches() {
335        let verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
336        let policy = make_policy_with_approval(&["curl_pipe_shell"]);
337
338        let meta = check_approval(&verdict, &policy);
339        assert!(meta.is_some());
340        let meta = meta.unwrap();
341        assert!(meta.requires_approval);
342        assert_eq!(meta.timeout_secs, 30);
343        assert_eq!(meta.fallback, "block");
344        assert_eq!(meta.rule_id, "curl_pipe_shell");
345    }
346
347    #[test]
348    fn test_check_approval_no_match() {
349        let verdict = make_verdict(RuleId::NonAsciiHostname, Severity::Medium);
350        let policy = make_policy_with_approval(&["curl_pipe_shell"]);
351
352        let meta = check_approval(&verdict, &policy);
353        assert!(meta.is_none());
354    }
355
356    #[test]
357    fn test_check_approval_empty_rules() {
358        let verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
359        let policy = Policy::default();
360
361        let meta = check_approval(&verdict, &policy);
362        assert!(meta.is_none());
363    }
364
365    #[test]
366    fn test_sanitize_description_basic() {
367        assert_eq!(
368            sanitize_description("Normal text with (parens) and 123"),
369            "Normal text with (parens) and 123"
370        );
371    }
372
373    #[test]
374    fn test_sanitize_description_strips_dangerous() {
375        assert_eq!(
376            sanitize_description("echo $HOME; rm -rf /; `whoami`"),
377            "echo HOME rm -rf / whoami"
378        );
379    }
380
381    #[test]
382    fn test_sanitize_description_collapses_spaces() {
383        assert_eq!(
384            sanitize_description("too   many    spaces"),
385            "too many spaces"
386        );
387    }
388
389    #[test]
390    fn test_sanitize_description_truncates() {
391        let long = "a".repeat(300);
392        let result = sanitize_description(&long);
393        assert!(result.len() <= 200);
394        assert!(result.ends_with("..."));
395    }
396
397    #[test]
398    fn test_sanitize_rule_id() {
399        assert_eq!(sanitize_rule_id("curl_pipe_shell"), "curl_pipe_shell");
400        // Uppercase letters are stripped (only [a-z_] allowed)
401        assert_eq!(sanitize_rule_id("CurlPipeShell"), "urlipehell");
402        assert_eq!(sanitize_rule_id(&"a".repeat(100)), "a".repeat(64));
403    }
404
405    #[test]
406    fn test_sanitize_fallback() {
407        assert_eq!(sanitize_fallback("block"), "block");
408        assert_eq!(sanitize_fallback("warn"), "warn");
409        assert_eq!(sanitize_fallback("allow"), "allow");
410        assert_eq!(sanitize_fallback("BLOCK"), "block");
411        assert_eq!(sanitize_fallback("  warn  "), "warn");
412        // Malicious values default to "block" (fail-closed).
413        assert_eq!(sanitize_fallback("block\nINJECTED=yes"), "block");
414        assert_eq!(
415            sanitize_fallback("allow\r\nTIRITH_REQUIRES_APPROVAL=no"),
416            "block"
417        );
418        assert_eq!(sanitize_fallback(""), "block");
419        assert_eq!(sanitize_fallback("invalid"), "block");
420    }
421
422    #[test]
423    fn test_apply_approval() {
424        let mut verdict = make_verdict(RuleId::CurlPipeShell, Severity::High);
425        let meta = ApprovalMetadata {
426            requires_approval: true,
427            timeout_secs: 60,
428            fallback: "warn".to_string(),
429            rule_id: "curl_pipe_shell".to_string(),
430            description: "Pipe to shell detected".to_string(),
431        };
432        apply_approval(&mut verdict, &meta);
433
434        assert_eq!(verdict.requires_approval, Some(true));
435        assert_eq!(verdict.approval_timeout_secs, Some(60));
436        assert_eq!(verdict.approval_fallback.as_deref(), Some("warn"));
437        assert_eq!(verdict.approval_rule.as_deref(), Some("curl_pipe_shell"));
438    }
439
440    #[test]
441    fn test_write_approval_file() {
442        let meta = ApprovalMetadata {
443            requires_approval: true,
444            timeout_secs: 30,
445            fallback: "block".to_string(),
446            rule_id: "curl_pipe_shell".to_string(),
447            description: "Pipe to shell detected".to_string(),
448        };
449
450        let path = write_approval_file(&meta).expect("write should succeed");
451        assert!(path.exists());
452
453        let content = std::fs::read_to_string(&path).expect("read should succeed");
454        assert!(content.contains("TIRITH_REQUIRES_APPROVAL=yes"));
455        assert!(content.contains("TIRITH_APPROVAL_TIMEOUT=30"));
456        assert!(content.contains("TIRITH_APPROVAL_FALLBACK=block"));
457        assert!(content.contains("TIRITH_APPROVAL_RULE=curl_pipe_shell"));
458        assert!(content.contains("TIRITH_APPROVAL_DESCRIPTION=Pipe to shell detected"));
459
460        #[cfg(unix)]
461        {
462            use std::os::unix::fs::PermissionsExt;
463            let perms = std::fs::metadata(&path).unwrap().permissions();
464            assert_eq!(perms.mode() & 0o777, 0o600);
465        }
466
467        let _ = std::fs::remove_file(&path);
468    }
469
470    #[test]
471    fn test_write_no_approval_file() {
472        let path = write_no_approval_file().expect("write should succeed");
473        assert!(path.exists());
474
475        let content = std::fs::read_to_string(&path).expect("read should succeed");
476        assert!(content.contains("TIRITH_REQUIRES_APPROVAL=no"));
477        assert!(!content.contains("TIRITH_APPROVAL_TIMEOUT"));
478
479        let _ = std::fs::remove_file(&path);
480    }
481
482    #[test]
483    fn test_write_warn_ack_file() {
484        let path = write_warn_ack_file(3, &Severity::Medium).expect("write should succeed");
485        assert!(path.exists());
486
487        let content = std::fs::read_to_string(&path).expect("read should succeed");
488        assert!(content.contains("TIRITH_WARN_ACK_REQUIRED=yes"));
489        assert!(content.contains("TIRITH_WARN_ACK_FINDINGS=3"));
490        assert!(content.contains("TIRITH_WARN_ACK_MAX_SEVERITY=MEDIUM"));
491
492        #[cfg(unix)]
493        {
494            use std::os::unix::fs::PermissionsExt;
495            let perms = std::fs::metadata(&path).unwrap().permissions();
496            assert_eq!(perms.mode() & 0o777, 0o600);
497        }
498
499        let _ = std::fs::remove_file(&path);
500    }
501
502    #[test]
503    fn write_approval_file_cleans_up_stale_leaks() {
504        // Regression guard: a `tirith check --approval-check` invoked from a
505        // terminal (or any caller that doesn't consume the temp file) used to
506        // leak `tirith-approval-*.env` into $TEMP forever. The next write must
507        // opportunistically remove leaked files older than the TTL — and must
508        // NOT touch fresh files (a concurrent hook may still be reading them)
509        // or unrelated files.
510        use std::fs::File;
511        use std::time::{Duration, SystemTime};
512
513        let dir = std::env::temp_dir();
514
515        // Unique-enough suffix so parallel runs of this suite don't interfere.
516        let suffix = format!("{}-{}", std::process::id(), rand_token());
517        let stale = dir.join(format!("tirith-approval-stale-{suffix}.env"));
518        let fresh = dir.join(format!("tirith-approval-fresh-{suffix}.env"));
519        let unrelated = dir.join(format!("tirith-other-{suffix}.env"));
520
521        File::create(&stale).expect("stale create");
522        File::create(&fresh).expect("fresh create");
523        File::create(&unrelated).expect("unrelated create");
524
525        // Backdate the stale file past the TTL.
526        let two_hours_ago = SystemTime::now() - Duration::from_secs(7200);
527        File::options()
528            .write(true)
529            .open(&stale)
530            .and_then(|f| f.set_modified(two_hours_ago))
531            .expect("backdate stale");
532
533        let meta = ApprovalMetadata {
534            requires_approval: true,
535            timeout_secs: 0,
536            fallback: "block".to_string(),
537            rule_id: "test".to_string(),
538            description: "test".to_string(),
539        };
540        let new_path = write_approval_file(&meta).expect("write should succeed");
541
542        assert!(!stale.exists(), "stale leak should be cleaned up");
543        assert!(fresh.exists(), "fresh file (within TTL) must be left alone");
544        assert!(
545            unrelated.exists(),
546            "unrelated file (wrong prefix) must be left alone"
547        );
548        assert!(new_path.exists(), "new approval file must exist");
549
550        let _ = std::fs::remove_file(&fresh);
551        let _ = std::fs::remove_file(&unrelated);
552        let _ = std::fs::remove_file(&new_path);
553    }
554
555    fn rand_token() -> String {
556        use std::time::{SystemTime, UNIX_EPOCH};
557        let nanos = SystemTime::now()
558            .duration_since(UNIX_EPOCH)
559            .unwrap()
560            .as_nanos();
561        format!("{nanos:x}")
562    }
563}