@@ -212,6 +212,166 @@ fn run_audit(args: AuditArgs) {
212212 }
213213 }
214214
215+ // ── Deep MIR analysis (runs before Phase 2 so findings feed into export maps) ──
216+ if args. deep {
217+ let output_path = std:: env:: temp_dir ( )
218+ . join ( format ! ( "capsec-deep-{}.jsonl" , std:: process:: id( ) ) ) ;
219+
220+ let driver_available = capsec_std:: process:: command ( "which" , & spawn_cap)
221+ . ok ( )
222+ . and_then ( |mut cmd| cmd. arg ( "capsec-driver" ) . output ( ) . ok ( ) )
223+ . map ( |o| o. status . success ( ) )
224+ . unwrap_or ( false ) ;
225+
226+ if !driver_available {
227+ eprintln ! ( "Error: --deep requires capsec-driver (MIR analysis driver)." ) ;
228+ eprintln ! ( "Install with: cd crates/capsec-deep && cargo install --path ." ) ;
229+ eprintln ! ( "Continuing with syntactic-only analysis..." ) ;
230+ } else {
231+ let deep_target_dir = workspace_root. join ( "target/capsec-deep" ) ;
232+ let toolchain = {
233+ let pinned = "nightly-2026-02-17" ;
234+ let has_pinned = capsec_std:: process:: command ( "rustup" , & spawn_cap)
235+ . ok ( )
236+ . and_then ( |mut cmd| {
237+ cmd. arg ( "run" )
238+ . arg ( pinned)
239+ . arg ( "rustc" )
240+ . arg ( "--version" )
241+ . output ( )
242+ . ok ( )
243+ } )
244+ . map ( |o| o. status . success ( ) )
245+ . unwrap_or ( false ) ;
246+ if has_pinned { pinned } else { "nightly" }
247+ } ;
248+
249+ let _ = std:: fs:: remove_dir_all ( & deep_target_dir) ;
250+
251+ let deep_result = capsec_std:: process:: command ( "cargo" , & spawn_cap)
252+ . ok ( )
253+ . and_then ( |mut cmd| {
254+ cmd. arg ( "check" )
255+ . current_dir ( & path_arg)
256+ . env ( "RUSTC_WRAPPER" , "capsec-driver" )
257+ . env ( "CAPSEC_DEEP_OUTPUT" , & output_path)
258+ . env ( "CAPSEC_CRATE_VERSION" , "0.0.0" )
259+ . env ( "CARGO_TARGET_DIR" , & deep_target_dir)
260+ . env ( "RUSTUP_TOOLCHAIN" , toolchain)
261+ . output ( )
262+ . ok ( )
263+ } ) ;
264+
265+ // Build name/version lookup for patching MIR findings
266+ let crate_lookup: HashMap < String , ( String , String ) > = workspace_crates
267+ . iter ( )
268+ . chain ( dep_crates. iter ( ) )
269+ . map ( |c| {
270+ (
271+ discovery:: normalize_crate_name ( & c. name ) ,
272+ ( c. name . clone ( ) , c. version . clone ( ) ) ,
273+ )
274+ } )
275+ . collect ( ) ;
276+
277+ let mut mir_findings: Vec < detector:: Finding > = Vec :: new ( ) ;
278+
279+ match deep_result {
280+ Some ( output) if output. status . success ( ) || output_path. exists ( ) => {
281+ if let Ok ( contents) =
282+ capsec_std:: fs:: read_to_string ( & output_path, & fs_read)
283+ {
284+ for line in contents. lines ( ) {
285+ if line. trim ( ) . is_empty ( ) {
286+ continue ;
287+ }
288+ match serde_json:: from_str :: < detector:: Finding > ( line) {
289+ Ok ( mut finding) => {
290+ let normalized = discovery:: normalize_crate_name (
291+ & finding. crate_name ,
292+ ) ;
293+ if let Some ( ( cargo_name, ver) ) =
294+ crate_lookup. get ( & normalized)
295+ {
296+ finding. crate_name = cargo_name. clone ( ) ;
297+ if finding. crate_version == "0.0.0" {
298+ finding. crate_version = ver. clone ( ) ;
299+ }
300+ }
301+ mir_findings. push ( finding) ;
302+ }
303+ Err ( e) => {
304+ eprintln ! (
305+ "Warning: Failed to parse deep finding: {e}"
306+ ) ;
307+ }
308+ }
309+ }
310+ }
311+ let _ = std:: fs:: remove_file ( & output_path) ;
312+ }
313+ Some ( output) => {
314+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
315+ eprintln ! (
316+ "Warning: Deep analysis failed (cargo check returned non-zero)."
317+ ) ;
318+ for line in stderr
319+ . lines ( )
320+ . filter ( |l| l. contains ( "error" ) || l. contains ( "Error" ) )
321+ . take ( 5 )
322+ {
323+ eprintln ! ( " {line}" ) ;
324+ }
325+ if stderr. contains ( "incompatible version of rustc" ) {
326+ eprintln ! (
327+ " Hint: try `rm -rf target/capsec-deep` to clear stale artifacts."
328+ ) ;
329+ }
330+ eprintln ! ( "Continuing with syntactic-only findings." ) ;
331+ }
332+ None => {
333+ eprintln ! ( "Warning: Could not invoke cargo check for deep analysis." ) ;
334+ eprintln ! ( "Continuing with syntactic-only findings." ) ;
335+ }
336+ }
337+
338+ if !mir_findings. is_empty ( ) {
339+ eprintln ! (
340+ "Deep analysis: {} MIR-level findings. Building export maps..." ,
341+ mir_findings. len( )
342+ ) ;
343+
344+ // Build export maps from MIR findings so they propagate to Phase 2
345+ let mut mir_by_crate: HashMap < String , Vec < detector:: Finding > > = HashMap :: new ( ) ;
346+ for f in & mir_findings {
347+ mir_by_crate
348+ . entry ( f. crate_name . clone ( ) )
349+ . or_default ( )
350+ . push ( f. clone ( ) ) ;
351+ }
352+ for ( crate_name, findings) in & mir_by_crate {
353+ let normalized = discovery:: normalize_crate_name ( crate_name) ;
354+ let src_dir = dep_crates
355+ . iter ( )
356+ . chain ( workspace_crates. iter ( ) )
357+ . find ( |c| discovery:: normalize_crate_name ( & c. name ) == normalized)
358+ . map ( |c| c. source_dir . clone ( ) )
359+ . unwrap_or_default ( ) ;
360+ let mir_emap = export_map:: build_export_map (
361+ & normalized,
362+ & findings[ 0 ] . crate_version ,
363+ findings,
364+ & src_dir,
365+ ) ;
366+ export_maps. push ( mir_emap) ;
367+ }
368+
369+ // Add MIR findings to the main collection
370+ all_findings. extend ( mir_findings) ;
371+ }
372+ }
373+ }
374+
215375 // Phase 2: Scan workspace crates with dependency export maps injected.
216376 // Process in topological order so workspace-to-workspace findings propagate
217377 // (e.g., radicle-cli depends on radicle → radicle scanned first).
@@ -357,158 +517,8 @@ fn run_audit(args: AuditArgs) {
357517 }
358518 }
359519
360- // ── Deep MIR analysis (optional, requires nightly + capsec-driver) ──
361- if args. deep {
362- let output_path =
363- std:: env:: temp_dir ( ) . join ( format ! ( "capsec-deep-{}.jsonl" , std:: process:: id( ) ) ) ;
364-
365- // Check if capsec-driver is available
366- let driver_check = capsec_std:: process:: command ( "capsec-driver" , & spawn_cap)
367- . ok ( )
368- . and_then ( |mut cmd| cmd. arg ( "--version" ) . output ( ) . ok ( ) ) ;
369-
370- if driver_check. is_none ( ) {
371- // Try finding it relative to ourselves or via which
372- let which_check = capsec_std:: process:: command ( "which" , & spawn_cap)
373- . ok ( )
374- . and_then ( |mut cmd| cmd. arg ( "capsec-driver" ) . output ( ) . ok ( ) )
375- . map ( |o| o. status . success ( ) )
376- . unwrap_or ( false ) ;
377-
378- if !which_check {
379- eprintln ! ( "Error: --deep requires capsec-driver (MIR analysis driver)." ) ;
380- eprintln ! ( "Install it with: cd crates/capsec-deep && cargo install --path ." ) ;
381- eprintln ! ( "Requires nightly Rust toolchain with rustc-dev component." ) ;
382- eprintln ! ( ) ;
383- eprintln ! ( "Continuing with syntactic-only analysis..." ) ;
384- }
385- }
386-
387- // Invoke cargo check with capsec-driver as RUSTC_WRAPPER (not WORKSPACE_WRAPPER).
388- // RUSTC_WRAPPER wraps ALL crate compilations including dependencies,
389- // so the MIR driver analyzes every crate in the dependency tree.
390- // Must use the EXACT nightly that capsec-driver was compiled against.
391- // Detect it by running capsec-driver to print its rustc version hash,
392- // or fall back to the pinned nightly from capsec-deep's toolchain file.
393- //
394- // We use RUSTUP_TOOLCHAIN to override the target project's rust-toolchain.toml.
395- // We also clean the deep target dir on first run to avoid stale .rmeta conflicts.
396- let deep_target_dir = workspace_root. join ( "target/capsec-deep" ) ;
397-
398- // Detect which nightly capsec-driver needs by checking its linked rustc version
399- let toolchain = {
400- // Try the pinned date-based nightly first (most reliable)
401- let pinned = "nightly-2026-02-17" ;
402- // Verify it exists
403- let has_pinned = capsec_std:: process:: command ( "rustup" , & spawn_cap)
404- . ok ( )
405- . and_then ( |mut cmd| {
406- cmd. arg ( "run" )
407- . arg ( pinned)
408- . arg ( "rustc" )
409- . arg ( "--version" )
410- . output ( )
411- . ok ( )
412- } )
413- . map ( |o| o. status . success ( ) )
414- . unwrap_or ( false ) ;
415-
416- if has_pinned { pinned } else { "nightly" }
417- } ;
418-
419- // Clean the deep target dir to force a full rebuild.
420- // Without this, `cargo check` sees cached .rmeta and doesn't invoke the driver,
421- // producing zero MIR findings on subsequent runs.
422- let _ = std:: fs:: remove_dir_all ( & deep_target_dir) ;
423-
424- let deep_result = capsec_std:: process:: command ( "cargo" , & spawn_cap)
425- . ok ( )
426- . and_then ( |mut cmd| {
427- cmd. arg ( "check" )
428- . current_dir ( & path_arg)
429- . env ( "RUSTC_WRAPPER" , "capsec-driver" )
430- . env ( "CAPSEC_DEEP_OUTPUT" , & output_path)
431- . env ( "CAPSEC_CRATE_VERSION" , "0.0.0" )
432- . env ( "CARGO_TARGET_DIR" , & deep_target_dir)
433- . env ( "RUSTUP_TOOLCHAIN" , toolchain)
434- . output ( )
435- . ok ( )
436- } ) ;
437-
438- match deep_result {
439- Some ( output) if output. status . success ( ) || output_path. exists ( ) => {
440- // Build lookups: normalized_name → (cargo_name, version)
441- // The MIR driver uses rustc's crate name (underscored: radicle_cli)
442- // but the syntactic scanner uses Cargo's package name (hyphenated: radicle-cli).
443- // We need to map MIR names back to the canonical Cargo names.
444- let crate_lookup: HashMap < String , ( String , String ) > = workspace_crates
445- . iter ( )
446- . chain ( dep_crates. iter ( ) )
447- . map ( |c| {
448- (
449- discovery:: normalize_crate_name ( & c. name ) ,
450- ( c. name . clone ( ) , c. version . clone ( ) ) ,
451- )
452- } )
453- . collect ( ) ;
454-
455- // Read JSONL findings
456- if let Ok ( contents) = capsec_std:: fs:: read_to_string ( & output_path, & fs_read) {
457- let mut deep_count = 0 ;
458- for line in contents. lines ( ) {
459- if line. trim ( ) . is_empty ( ) {
460- continue ;
461- }
462- match serde_json:: from_str :: < detector:: Finding > ( line) {
463- Ok ( mut finding) => {
464- // Patch crate name and version: MIR driver uses rustc
465- // identifiers (underscored) and doesn't know Cargo versions.
466- let normalized =
467- discovery:: normalize_crate_name ( & finding. crate_name ) ;
468- if let Some ( ( cargo_name, ver) ) = crate_lookup. get ( & normalized) {
469- finding. crate_name = cargo_name. clone ( ) ;
470- if finding. crate_version == "0.0.0" {
471- finding. crate_version = ver. clone ( ) ;
472- }
473- }
474- all_findings. push ( finding) ;
475- deep_count += 1 ;
476- }
477- Err ( e) => {
478- eprintln ! ( "Warning: Failed to parse deep finding: {e}" ) ;
479- }
480- }
481- }
482- if deep_count > 0 {
483- eprintln ! ( "Deep analysis: {deep_count} MIR-level findings added." ) ;
484- }
485- }
486- // Clean up temp file
487- let _ = std:: fs:: remove_file ( & output_path) ;
488- }
489- Some ( output) => {
490- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
491- eprintln ! ( "Warning: Deep analysis failed (cargo check returned non-zero)." ) ;
492- // Show last few meaningful error lines
493- for line in stderr
494- . lines ( )
495- . filter ( |l| l. contains ( "error" ) || l. contains ( "Error" ) )
496- . take ( 5 )
497- {
498- eprintln ! ( " {line}" ) ;
499- }
500- if stderr. contains ( "incompatible version of rustc" ) {
501- eprintln ! ( " Hint: try `rm -rf target/capsec-deep` to clear stale artifacts." ) ;
502- }
503- eprintln ! ( "Continuing with syntactic-only findings." ) ;
504- }
505- None => {
506- eprintln ! ( "Warning: Could not invoke cargo check for deep analysis." ) ;
507- eprintln ! ( "Continuing with syntactic-only findings." ) ;
508- }
509- }
510-
511- // Dedup: if both syntactic and MIR found the same call site, keep one
520+ // Dedup: if both syntactic and MIR found the same call site, keep one
521+ {
512522 let mut seen = std:: collections:: HashSet :: new ( ) ;
513523 all_findings. retain ( |f| {
514524 seen. insert ( (
0 commit comments