Skip to main content

devflow_core/
verify.rs

1//! Explicitly operator-approved external post-condition verification.
2//!
3//! Commands are discovered only from PLAN.md YAML frontmatter. Because those
4//! files are agent-writable, execution additionally requires the parent
5//! process's [`TRUST_EXTERNAL_VERIFY_ENV`] authorization.
6
7use std::path::{Path, PathBuf};
8
9/// Explicit operator-owned approval for executing PLAN-declared shell.
10pub const TRUST_EXTERNAL_VERIFY_ENV: &str = "DEVFLOW_TRUST_EXTERNAL_VERIFY";
11
12/// Return the exact command bytes approved by the operator.
13///
14/// The value is a JSON string array. Comparing it to the commands reread
15/// after Code closes the review-to-execution TOCTOU: a modified PLAN fails
16/// closed instead of inheriting a blanket boolean authorization.
17pub fn external_verification_approval() -> Option<Vec<String>> {
18    let value = std::env::var(TRUST_EXTERNAL_VERIFY_ENV).ok()?;
19    let commands = serde_json::from_str::<Vec<String>>(&value).ok()?;
20    (!commands.is_empty()).then_some(commands)
21}
22
23/// Return external verification commands declared by this phase's plans.
24///
25/// Only the first YAML frontmatter block is inspected. This intentionally
26/// small parser recognizes the scalar shape established by Phase 16:
27/// `external_verify: "command"` (single-quoted and unquoted scalars are also
28/// accepted). Runtime captures and agent output are never read here.
29pub fn external_verify_commands(project_root: &Path, phase: u32) -> Vec<String> {
30    let phases_dir = project_root.join(".planning/phases");
31    let phase_prefix = format!("{phase:02}-");
32    let plan_prefix = format!("{phase:02}-");
33    let mut plans = Vec::<PathBuf>::new();
34
35    let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
36        return Vec::new();
37    };
38    for phase_entry in phase_entries.flatten() {
39        if !phase_entry
40            .file_name()
41            .to_string_lossy()
42            .starts_with(&phase_prefix)
43        {
44            continue;
45        }
46        let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
47            continue;
48        };
49        plans.extend(plan_entries.flatten().filter_map(|entry| {
50            let name = entry.file_name();
51            let name = name.to_string_lossy();
52            (name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
53        }));
54    }
55    plans.sort();
56
57    plans
58        .into_iter()
59        .filter_map(|path| std::fs::read_to_string(path).ok())
60        .filter_map(|contents| command_from_frontmatter(&contents))
61        .collect()
62}
63
64fn command_from_frontmatter(contents: &str) -> Option<String> {
65    let mut lines = contents.lines();
66    if lines.next()?.trim() != "---" {
67        return None;
68    }
69
70    for line in lines {
71        let line = line.trim();
72        if line == "---" {
73            break;
74        }
75        let Some(value) = line.strip_prefix("external_verify:") else {
76            continue;
77        };
78        let value = value.trim();
79        if value.is_empty() {
80            return None;
81        }
82        if value.starts_with('"') {
83            return serde_json::from_str::<String>(value).ok();
84        }
85        if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
86            return Some(value[1..value.len() - 1].replace("''", "'"));
87        }
88        return Some(value.to_owned());
89    }
90    None
91}
92
93/// Run one explicitly operator-approved external verification command.
94///
95/// `sh -c` is intentional because probes may contain pipelines. The caller
96/// must source `cmd` from [`external_verify_commands`] and first require
97/// [`external_verification_approval`]. Spawn failures and non-zero exits fail
98/// closed.
99pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
100    std::process::Command::new("sh")
101        .arg("-c")
102        .arg(cmd)
103        .current_dir(project_root)
104        .output()
105        .map(|output| output.status.success())
106        .unwrap_or(false)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn write_plan(root: &std::path::Path, contents: &str) {
114        let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
115        std::fs::create_dir_all(&phase_dir).unwrap();
116        std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
117    }
118
119    #[test]
120    fn reads_external_verify_only_from_plan_frontmatter() {
121        let dir = tempfile::tempdir().unwrap();
122        write_plan(
123            dir.path(),
124            "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
125        );
126        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
127        std::fs::write(
128            dir.path().join(".devflow/phase-16-stdout"),
129            "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
130        )
131        .unwrap();
132
133        assert_eq!(
134            external_verify_commands(dir.path(), 16),
135            vec!["test -f shipped.txt"]
136        );
137    }
138
139    #[test]
140    fn ignores_external_verify_outside_frontmatter() {
141        let dir = tempfile::tempdir().unwrap();
142        write_plan(
143            dir.path(),
144            "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
145        );
146
147        assert!(external_verify_commands(dir.path(), 16).is_empty());
148    }
149
150    #[test]
151    fn runs_probe_from_project_root_and_reports_exit_status() {
152        let dir = tempfile::tempdir().unwrap();
153        std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
154
155        assert!(run_external_verification("test -f shipped.txt", dir.path()));
156        assert!(!run_external_verification(
157            "test -f missing.txt",
158            dir.path()
159        ));
160    }
161}