1use std::io::Write;
2use std::path::PathBuf;
3
4use crate::policy::{ApprovalRule, Policy};
5use crate::verdict::Verdict;
6
7#[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
17pub fn check_approval(verdict: &Verdict, policy: &Policy) -> Option<ApprovalMetadata> {
22 if policy.approval_rules.is_empty() {
23 return None;
24 }
25
26 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
50pub 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
59pub 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 #[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 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 let (_, path) = tmp.keep().map_err(|e| e.error)?;
112 Ok(path)
113}
114
115pub 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
136fn 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
141pub 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 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 if result.len() > 200 {
174 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
186fn 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
200fn 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(); 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 assert_eq!(sanitize_rule_id("curl_pipe_shell"), "curl_pipe_shell");
325 assert_eq!(sanitize_rule_id("CurlPipeShell"), "urlipehell");
327 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 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 #[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 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}