Skip to main content

mdx_rust_core/
hardening.rs

1//! Safe scoped hardening for ordinary Rust modules.
2//!
3//! This is the safe hardening path for non-agent Rust code. It reuses the same
4//! boring safety philosophy as the optimizer: analyze, propose, validate in
5//! isolation, and only touch the real tree when explicitly requested.
6
7use crate::eval::{run_behavior_evals, stable_hash_hex, BehaviorEvalReport};
8use crate::policy::{load_project_policy, PolicyCategory, ProjectPolicy};
9use mdx_rust_analysis::editing::{
10    cleanup_isolated_workspace, create_isolated_workspace, restore_transaction,
11    snapshot_transaction, validate_build_detailed_with_budget, ValidationCommandRecord,
12};
13use mdx_rust_analysis::{
14    analyze_hardening, HardeningAnalyzeConfig, HardeningFileChange, HardeningFinding,
15};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use std::path::{Component, Path, PathBuf};
19use std::time::Duration;
20
21#[derive(Debug, Clone)]
22pub struct HardeningConfig {
23    pub target: Option<PathBuf>,
24    pub policy_path: Option<PathBuf>,
25    pub behavior_spec_path: Option<PathBuf>,
26    pub apply: bool,
27    pub max_files: usize,
28    pub validation_timeout: Duration,
29}
30
31impl Default for HardeningConfig {
32    fn default() -> Self {
33        Self {
34            target: None,
35            policy_path: None,
36            behavior_spec_path: None,
37            apply: false,
38            max_files: 100,
39            validation_timeout: Duration::from_secs(180),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct HardeningRun {
46    pub schema_version: String,
47    pub root: String,
48    pub target: Option<String>,
49    pub mode: HardeningMode,
50    pub workspace: WorkspaceSummary,
51    pub policy: Option<ProjectPolicy>,
52    pub policy_matches: Vec<PolicyFindingMatch>,
53    pub risk_summary: HardeningRiskSummary,
54    pub files_scanned: usize,
55    pub findings: Vec<HardeningFinding>,
56    pub changes: Vec<HardeningChangeSummary>,
57    pub behavior_evaluation: Option<BehaviorEvalReport>,
58    pub final_behavior_evaluation: Option<BehaviorEvalReport>,
59    pub outcome: HardeningOutcome,
60    pub artifact_path: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
64pub enum HardeningMode {
65    Review,
66    Apply,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
70pub struct WorkspaceSummary {
71    pub cargo_metadata_available: bool,
72    pub package_count: usize,
73    pub package_names: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77pub struct PolicyFindingMatch {
78    pub finding_id: String,
79    pub rule_id: String,
80    pub category: PolicyCategory,
81    pub reason: String,
82}
83
84pub type HardeningPolicyRecord = ProjectPolicy;
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
87pub struct HardeningRiskSummary {
88    pub high: usize,
89    pub medium: usize,
90    pub low: usize,
91    pub patchable: usize,
92    pub top_recommendations: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
96pub struct HardeningChangeSummary {
97    pub file: String,
98    pub strategy: String,
99    pub finding_ids: Vec<String>,
100    pub description: String,
101    pub old_hash: String,
102    pub new_hash: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct HardeningOutcome {
107    pub status: HardeningStatus,
108    pub isolated_validation_passed: bool,
109    pub applied: bool,
110    pub final_validation_passed: bool,
111    pub validation_commands: Vec<ValidationCommandRecord>,
112    pub final_validation_commands: Vec<ValidationCommandRecord>,
113    pub behavior_evaluation: Option<BehaviorEvalReport>,
114    pub final_behavior_evaluation: Option<BehaviorEvalReport>,
115    pub rollback_succeeded: Option<bool>,
116    pub rollback_error: Option<String>,
117    pub note: String,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121pub enum HardeningStatus {
122    NoChanges,
123    Reviewed,
124    Applied,
125    ValidationFailed,
126    BehaviorValidationFailed,
127    FinalValidationFailedRolledBack,
128    Rejected,
129}
130
131pub fn run_hardening(
132    root: &Path,
133    artifact_root: Option<&Path>,
134    config: &HardeningConfig,
135) -> anyhow::Result<HardeningRun> {
136    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
137    let analysis = analyze_hardening(
138        &root,
139        HardeningAnalyzeConfig {
140            target: config.target.as_deref(),
141            max_files: config.max_files,
142        },
143    )?;
144    let workspace = workspace_summary(&root);
145    let policy = load_project_policy(&root, config.policy_path.as_deref())?;
146    let policy_matches = policy
147        .as_ref()
148        .map(|policy| match_findings_to_policy(&analysis.findings, policy))
149        .unwrap_or_default();
150    let mode = if config.apply {
151        HardeningMode::Apply
152    } else {
153        HardeningMode::Review
154    };
155    let changes = summarize_changes(&analysis.changes);
156    let risk_summary = summarize_risk(&analysis.findings);
157
158    let outcome = if analysis.changes.is_empty() {
159        HardeningOutcome {
160            status: HardeningStatus::NoChanges,
161            isolated_validation_passed: false,
162            applied: false,
163            final_validation_passed: false,
164            validation_commands: Vec::new(),
165            final_validation_commands: Vec::new(),
166            behavior_evaluation: None,
167            final_behavior_evaluation: None,
168            rollback_succeeded: None,
169            rollback_error: None,
170            note: "no high-confidence hardening changes were available".to_string(),
171        }
172    } else {
173        execute_hardening_changes(&root, &analysis.changes, config)?
174    };
175
176    let mut run = HardeningRun {
177        schema_version: "0.4".to_string(),
178        root: root.display().to_string(),
179        target: config
180            .target
181            .as_ref()
182            .map(|path| path.display().to_string()),
183        mode,
184        workspace,
185        policy,
186        policy_matches,
187        risk_summary,
188        files_scanned: analysis.files_scanned,
189        findings: analysis.findings,
190        changes,
191        behavior_evaluation: outcome.behavior_evaluation.clone(),
192        final_behavior_evaluation: outcome.final_behavior_evaluation.clone(),
193        outcome,
194        artifact_path: None,
195    };
196
197    if let Some(artifact_root) = artifact_root {
198        let path = persist_hardening_run(artifact_root, &run)?;
199        run.artifact_path = Some(path.display().to_string());
200        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
201    }
202
203    Ok(run)
204}
205
206fn execute_hardening_changes(
207    root: &Path,
208    changes: &[HardeningFileChange],
209    config: &HardeningConfig,
210) -> anyhow::Result<HardeningOutcome> {
211    ensure_scoped_changes(root, changes)?;
212    let behavior_spec_path = config
213        .behavior_spec_path
214        .as_deref()
215        .map(|path| resolve_behavior_spec_path(root, path));
216
217    let isolated = create_isolated_workspace(root, "hardening-v0-4")?;
218    write_changes(&isolated, changes)?;
219    let validation = validate_build_detailed_with_budget(&isolated, config.validation_timeout);
220    let behavior_evaluation = if validation.passed {
221        run_optional_behavior_eval(&isolated, behavior_spec_path.as_deref())?
222    } else {
223        None
224    };
225    cleanup_isolated_workspace(root, &isolated);
226
227    if !validation.passed {
228        return Ok(HardeningOutcome {
229            status: HardeningStatus::ValidationFailed,
230            isolated_validation_passed: false,
231            applied: false,
232            final_validation_passed: false,
233            validation_commands: validation.command_records,
234            final_validation_commands: Vec::new(),
235            rollback_succeeded: None,
236            rollback_error: None,
237            note: "proposed hardening changes failed isolated validation".to_string(),
238            behavior_evaluation: None,
239            final_behavior_evaluation: None,
240        });
241    }
242
243    if behavior_evaluation
244        .as_ref()
245        .is_some_and(|report| !report.passed())
246    {
247        return Ok(HardeningOutcome {
248            status: HardeningStatus::BehaviorValidationFailed,
249            isolated_validation_passed: true,
250            applied: false,
251            final_validation_passed: false,
252            validation_commands: validation.command_records,
253            final_validation_commands: Vec::new(),
254            rollback_succeeded: None,
255            rollback_error: None,
256            note: "proposed hardening changes failed behavior evaluation".to_string(),
257            behavior_evaluation,
258            final_behavior_evaluation: None,
259        });
260    }
261
262    if !config.apply {
263        return Ok(HardeningOutcome {
264            status: HardeningStatus::Reviewed,
265            isolated_validation_passed: true,
266            applied: false,
267            final_validation_passed: false,
268            validation_commands: validation.command_records,
269            final_validation_commands: Vec::new(),
270            rollback_succeeded: None,
271            rollback_error: None,
272            note: "changes validated in isolation; rerun with --apply to land them".to_string(),
273            behavior_evaluation,
274            final_behavior_evaluation: None,
275        });
276    }
277
278    let real_paths: Vec<PathBuf> = changes
279        .iter()
280        .map(|change| root.join(&change.file))
281        .collect();
282    let snapshot = snapshot_transaction(&real_paths)?;
283    write_changes(root, changes)?;
284    let final_validation = validate_build_detailed_with_budget(root, config.validation_timeout);
285    let final_behavior_evaluation = if final_validation.passed {
286        run_optional_behavior_eval(root, behavior_spec_path.as_deref())?
287    } else {
288        None
289    };
290
291    if final_validation.passed
292        && final_behavior_evaluation
293            .as_ref()
294            .map(BehaviorEvalReport::passed)
295            .unwrap_or(true)
296    {
297        return Ok(HardeningOutcome {
298            status: HardeningStatus::Applied,
299            isolated_validation_passed: true,
300            applied: true,
301            final_validation_passed: true,
302            validation_commands: validation.command_records,
303            final_validation_commands: final_validation.command_records,
304            rollback_succeeded: None,
305            rollback_error: None,
306            note: "hardening changes applied and final validation passed".to_string(),
307            behavior_evaluation,
308            final_behavior_evaluation,
309        });
310    }
311
312    let rollback = restore_transaction(&snapshot);
313    let rollback_error = rollback.as_ref().err().map(ToString::to_string);
314    Ok(HardeningOutcome {
315        status: HardeningStatus::FinalValidationFailedRolledBack,
316        isolated_validation_passed: true,
317        applied: false,
318        final_validation_passed: false,
319        validation_commands: validation.command_records,
320        final_validation_commands: final_validation.command_records,
321        rollback_succeeded: Some(rollback.is_ok()),
322        rollback_error,
323        note: if final_validation.passed {
324            "final behavior evaluation failed; transaction rollback attempted".to_string()
325        } else {
326            "final validation failed; transaction rollback attempted".to_string()
327        },
328        behavior_evaluation,
329        final_behavior_evaluation,
330    })
331}
332
333fn run_optional_behavior_eval(
334    root: &Path,
335    spec_path: Option<&Path>,
336) -> anyhow::Result<Option<BehaviorEvalReport>> {
337    let Some(spec_path) = spec_path else {
338        return Ok(None);
339    };
340    Ok(Some(run_behavior_evals(root, spec_path)?))
341}
342
343fn resolve_behavior_spec_path(root: &Path, spec_path: &Path) -> PathBuf {
344    if spec_path.is_absolute() {
345        spec_path.to_path_buf()
346    } else {
347        root.join(spec_path)
348    }
349}
350
351fn ensure_scoped_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
352    if changes.is_empty() {
353        anyhow::bail!("hardening transaction has no changes");
354    }
355    for change in changes {
356        if change.file.components().any(|component| {
357            matches!(
358                component,
359                Component::ParentDir | Component::RootDir | Component::Prefix(_)
360            )
361        }) {
362            anyhow::bail!("unscoped hardening path: {}", change.file.display());
363        }
364        let target = root.join(&change.file);
365        if !target.starts_with(root) {
366            anyhow::bail!("hardening path escapes root: {}", change.file.display());
367        }
368    }
369    Ok(())
370}
371
372fn write_changes(root: &Path, changes: &[HardeningFileChange]) -> anyhow::Result<()> {
373    for change in changes {
374        let path = root.join(&change.file);
375        if let Some(parent) = path.parent() {
376            std::fs::create_dir_all(parent)?;
377        }
378        std::fs::write(path, &change.new_content)?;
379    }
380    Ok(())
381}
382
383fn summarize_changes(changes: &[HardeningFileChange]) -> Vec<HardeningChangeSummary> {
384    changes
385        .iter()
386        .map(|change| HardeningChangeSummary {
387            file: change.file.display().to_string(),
388            strategy: format!("{:?}", change.strategy),
389            finding_ids: change.finding_ids.clone(),
390            description: change.description.clone(),
391            old_hash: stable_hash_hex(change.old_content.as_bytes()),
392            new_hash: stable_hash_hex(change.new_content.as_bytes()),
393        })
394        .collect()
395}
396
397fn match_findings_to_policy(
398    findings: &[HardeningFinding],
399    policy: &ProjectPolicy,
400) -> Vec<PolicyFindingMatch> {
401    findings
402        .iter()
403        .filter_map(|finding| {
404            let category = category_for_finding(finding);
405            let rule = policy
406                .rules
407                .iter()
408                .find(|rule| rule.category == category)
409                .or_else(|| {
410                    policy
411                        .rules
412                        .iter()
413                        .find(|rule| rule.category == PolicyCategory::General)
414                })?;
415            Some(PolicyFindingMatch {
416                finding_id: finding.id.clone(),
417                rule_id: rule.id.clone(),
418                category: rule.category.clone(),
419                reason: format!("finding maps to policy rule on line {}", rule.line),
420            })
421        })
422        .collect()
423}
424
425fn category_for_finding(finding: &HardeningFinding) -> PolicyCategory {
426    match finding.strategy {
427        mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext => PolicyCategory::PanicSafety,
428        mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview => {
429            PolicyCategory::ProcessExecution
430        }
431        mdx_rust_analysis::HardeningStrategy::UnsafeReview => PolicyCategory::UnsafeCode,
432        mdx_rust_analysis::HardeningStrategy::EnvAccessReview => PolicyCategory::Environment,
433        mdx_rust_analysis::HardeningStrategy::FileIoReview => PolicyCategory::ErrorContext,
434        mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => PolicyCategory::InputValidation,
435    }
436}
437
438fn summarize_risk(findings: &[HardeningFinding]) -> HardeningRiskSummary {
439    let mut summary = HardeningRiskSummary {
440        high: 0,
441        medium: 0,
442        low: 0,
443        patchable: findings.iter().filter(|finding| finding.patchable).count(),
444        top_recommendations: Vec::new(),
445    };
446
447    let mut saw_patchable = false;
448    let mut saw_process = false;
449    let mut saw_http = false;
450    let mut saw_file = false;
451
452    for finding in findings {
453        match finding.strategy {
454            mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
455            | mdx_rust_analysis::HardeningStrategy::UnsafeReview => summary.high += 1,
456            mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext
457            | mdx_rust_analysis::HardeningStrategy::EnvAccessReview
458            | mdx_rust_analysis::HardeningStrategy::FileIoReview
459            | mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview => summary.medium += 1,
460        }
461
462        saw_patchable |= finding.patchable;
463        saw_process |= matches!(
464            finding.strategy,
465            mdx_rust_analysis::HardeningStrategy::ProcessExecutionReview
466        );
467        saw_http |= matches!(
468            finding.strategy,
469            mdx_rust_analysis::HardeningStrategy::HttpSurfaceReview
470        );
471        saw_file |= matches!(
472            finding.strategy,
473            mdx_rust_analysis::HardeningStrategy::FileIoReview
474        );
475    }
476
477    if saw_patchable {
478        summary
479            .top_recommendations
480            .push("Run mdx-rust improve <target> --apply after reviewing the proposed contextual error changes.".to_string());
481    }
482    if saw_process {
483        summary.top_recommendations.push(
484            "Review process execution callsites for allowlisted commands and validated arguments."
485                .to_string(),
486        );
487    }
488    if saw_http {
489        summary.top_recommendations.push(
490            "Review HTTP route surfaces for request validation and typed error handling."
491                .to_string(),
492        );
493    }
494    if saw_file {
495        summary.top_recommendations.push(
496            "Review filesystem boundaries for contextual errors and path validation.".to_string(),
497        );
498    }
499
500    summary
501}
502
503fn persist_hardening_run(artifact_root: &Path, run: &HardeningRun) -> anyhow::Result<PathBuf> {
504    let dir = artifact_root.join("hardening");
505    std::fs::create_dir_all(&dir)?;
506    let millis = std::time::SystemTime::now()
507        .duration_since(std::time::UNIX_EPOCH)
508        .map(|duration| duration.as_millis())
509        .unwrap_or(0);
510    let mode = match run.mode {
511        HardeningMode::Review => "review",
512        HardeningMode::Apply => "apply",
513    };
514    Ok(dir.join(format!("hardening-{mode}-{millis}.json")))
515}
516
517pub(crate) fn workspace_summary(root: &Path) -> WorkspaceSummary {
518    let mut command = std::process::Command::new("cargo");
519    command
520        .current_dir(root)
521        .args(["metadata", "--no-deps", "--format-version", "1"]);
522    let Some(output) =
523        mdx_rust_analysis::editing::run_command_with_timeout(&mut command, Duration::from_secs(20))
524    else {
525        return WorkspaceSummary {
526            cargo_metadata_available: false,
527            package_count: 0,
528            package_names: Vec::new(),
529        };
530    };
531
532    if !output.success() {
533        return WorkspaceSummary {
534            cargo_metadata_available: false,
535            package_count: 0,
536            package_names: Vec::new(),
537        };
538    }
539
540    let value: serde_json::Value = match serde_json::from_str(&output.stdout) {
541        Ok(value) => value,
542        Err(_) => {
543            return WorkspaceSummary {
544                cargo_metadata_available: false,
545                package_count: 0,
546                package_names: Vec::new(),
547            }
548        }
549    };
550    let package_names: Vec<String> = value
551        .get("packages")
552        .and_then(|packages| packages.as_array())
553        .map(|packages| {
554            packages
555                .iter()
556                .filter_map(|package| package.get("name").and_then(|name| name.as_str()))
557                .map(ToString::to_string)
558                .collect()
559        })
560        .unwrap_or_default();
561    WorkspaceSummary {
562        cargo_metadata_available: true,
563        package_count: package_names.len(),
564        package_names,
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use tempfile::tempdir;
572
573    fn write_fixture(root: &Path) {
574        std::fs::write(
575            root.join("Cargo.toml"),
576            r#"[package]
577name = "hardening-fixture"
578version = "0.1.0"
579edition = "2021"
580
581[dependencies]
582anyhow = "1"
583"#,
584        )
585        .unwrap();
586        let src = root.join("src");
587        std::fs::create_dir_all(&src).unwrap();
588        std::fs::write(
589            src.join("lib.rs"),
590            r#"pub fn load_config() -> anyhow::Result<String> {
591    let content = std::fs::read_to_string("missing.toml").unwrap();
592    Ok(content)
593}
594"#,
595        )
596        .unwrap();
597    }
598
599    fn write_behavior_spec(root: &Path, expected: &str) -> PathBuf {
600        let path = root.join("evals.json");
601        std::fs::write(
602            &path,
603            format!(
604                r#"{{
605  "version": "v1",
606  "commands": [
607    {{
608      "id": "cargo-check",
609      "command": "cargo",
610      "args": ["check"],
611      "expect_success": true,
612      "expect_stderr_contains": ["{expected}"],
613      "timeout_seconds": 120
614    }}
615  ]
616}}"#
617            ),
618        )
619        .unwrap();
620        path
621    }
622
623    fn write_artifact_behavior_spec(root: &Path) -> PathBuf {
624        let dir = root.join(".mdx-rust");
625        std::fs::create_dir_all(&dir).unwrap();
626        let path = dir.join("evals.json");
627        std::fs::write(
628            &path,
629            r#"{
630  "version": "v1",
631  "commands": [
632    {
633      "id": "cargo-check",
634      "command": "cargo",
635      "args": ["check"],
636      "expect_success": true,
637      "timeout_seconds": 120
638    }
639  ]
640}"#,
641        )
642        .unwrap();
643        PathBuf::from(".mdx-rust/evals.json")
644    }
645
646    #[test]
647    fn hardening_review_validates_without_touching_real_tree() {
648        let dir = tempdir().unwrap();
649        write_fixture(dir.path());
650        let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
651
652        let run = run_hardening(
653            dir.path(),
654            None,
655            &HardeningConfig {
656                target: Some(PathBuf::from("src/lib.rs")),
657                validation_timeout: Duration::from_secs(120),
658                ..HardeningConfig::default()
659            },
660        )
661        .unwrap();
662
663        let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
664        assert_eq!(before, after);
665        assert_eq!(run.outcome.status, HardeningStatus::Reviewed);
666        assert_eq!(run.schema_version, "0.4");
667        assert!(run.outcome.isolated_validation_passed);
668        assert!(!run.changes.is_empty());
669    }
670
671    #[test]
672    fn hardening_apply_lands_validated_transaction() {
673        let dir = tempdir().unwrap();
674        write_fixture(dir.path());
675
676        let run = run_hardening(
677            dir.path(),
678            None,
679            &HardeningConfig {
680                target: Some(PathBuf::from("src/lib.rs")),
681                apply: true,
682                validation_timeout: Duration::from_secs(120),
683                ..HardeningConfig::default()
684            },
685        )
686        .unwrap();
687
688        let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
689        assert_eq!(run.outcome.status, HardeningStatus::Applied);
690        assert!(run.outcome.final_validation_passed);
691        assert!(after.contains("use anyhow::Context;"));
692        assert!(after.contains(".context(\"load_config failed instead of panicking\")?"));
693    }
694
695    #[test]
696    fn hardening_maps_findings_to_structured_policy_rules() {
697        let dir = tempdir().unwrap();
698        write_fixture(dir.path());
699        std::fs::create_dir_all(dir.path().join(".mdx-rust")).unwrap();
700        std::fs::write(
701            dir.path().join(".mdx-rust/policies.md"),
702            "1. Avoid unwrap in service boundaries.",
703        )
704        .unwrap();
705
706        let run = run_hardening(
707            dir.path(),
708            None,
709            &HardeningConfig {
710                target: Some(PathBuf::from("src/lib.rs")),
711                validation_timeout: Duration::from_secs(120),
712                ..HardeningConfig::default()
713            },
714        )
715        .unwrap();
716
717        assert!(run.policy.is_some());
718        assert!(!run.policy_matches.is_empty());
719        assert_eq!(run.policy_matches[0].category, PolicyCategory::PanicSafety);
720    }
721
722    #[test]
723    fn hardening_behavior_eval_failure_blocks_apply() {
724        let dir = tempdir().unwrap();
725        write_fixture(dir.path());
726        let spec = write_behavior_spec(dir.path(), "definitely-not-in-cargo-output");
727        let before = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
728
729        let run = run_hardening(
730            dir.path(),
731            None,
732            &HardeningConfig {
733                target: Some(PathBuf::from("src/lib.rs")),
734                behavior_spec_path: Some(spec),
735                apply: true,
736                validation_timeout: Duration::from_secs(120),
737                ..HardeningConfig::default()
738            },
739        )
740        .unwrap();
741
742        let after = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
743        assert_eq!(before, after);
744        assert_eq!(
745            run.outcome.status,
746            HardeningStatus::BehaviorValidationFailed
747        );
748        assert!(run.outcome.behavior_evaluation.is_some());
749    }
750
751    #[test]
752    fn hardening_behavior_eval_spec_in_artifact_dir_runs_in_isolation() {
753        let dir = tempdir().unwrap();
754        write_fixture(dir.path());
755        let spec = write_artifact_behavior_spec(dir.path());
756
757        let run = run_hardening(
758            dir.path(),
759            None,
760            &HardeningConfig {
761                target: Some(PathBuf::from("src/lib.rs")),
762                behavior_spec_path: Some(spec),
763                apply: true,
764                validation_timeout: Duration::from_secs(120),
765                ..HardeningConfig::default()
766            },
767        )
768        .unwrap();
769
770        assert_eq!(run.outcome.status, HardeningStatus::Applied);
771        assert!(run
772            .outcome
773            .behavior_evaluation
774            .as_ref()
775            .is_some_and(BehaviorEvalReport::passed));
776        assert!(run
777            .outcome
778            .final_behavior_evaluation
779            .as_ref()
780            .is_some_and(BehaviorEvalReport::passed));
781    }
782
783    #[test]
784    fn hardening_rejects_unscoped_transaction_paths() {
785        let dir = tempdir().unwrap();
786        let changes = vec![HardeningFileChange {
787            file: PathBuf::from("../escape.rs"),
788            old_content: String::new(),
789            new_content: String::new(),
790            strategy: mdx_rust_analysis::HardeningStrategy::ResultUnwrapContext,
791            finding_ids: vec!["escape".to_string()],
792            description: "bad path".to_string(),
793        }];
794
795        let err = ensure_scoped_changes(dir.path(), &changes).unwrap_err();
796        assert!(err.to_string().contains("unscoped hardening path"));
797    }
798}