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    parse_external_verification_approval(&value)
20}
21
22fn parse_external_verification_approval(value: &str) -> Option<Vec<String>> {
23    let commands = serde_json::from_str::<Vec<String>>(value).ok()?;
24    (!commands.is_empty() && commands.iter().all(|command| !command.trim().is_empty()))
25        .then_some(commands)
26}
27
28/// Return external verification commands declared by this phase's plans.
29///
30/// Only the first YAML frontmatter block is inspected. This intentionally
31/// small parser recognizes the scalar shape established by Phase 16:
32/// `external_verify: "command"` (single-quoted and unquoted scalars are also
33/// accepted). Runtime captures and agent output are never read here.
34pub fn external_verify_commands(project_root: &Path, phase: u32) -> Vec<String> {
35    let phases_dir = project_root.join(".planning/phases");
36    let phase_prefix = format!("{phase:02}-");
37    let plan_prefix = format!("{phase:02}-");
38    let mut plans = Vec::<PathBuf>::new();
39
40    let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
41        return Vec::new();
42    };
43    for phase_entry in phase_entries.flatten() {
44        if !phase_entry
45            .file_name()
46            .to_string_lossy()
47            .starts_with(&phase_prefix)
48        {
49            continue;
50        }
51        let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
52            continue;
53        };
54        plans.extend(plan_entries.flatten().filter_map(|entry| {
55            let name = entry.file_name();
56            let name = name.to_string_lossy();
57            (name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
58        }));
59    }
60    plans.sort();
61
62    plans
63        .into_iter()
64        .filter_map(|path| std::fs::read_to_string(path).ok())
65        .filter_map(|contents| command_from_frontmatter(&contents))
66        .collect()
67}
68
69fn command_from_frontmatter(contents: &str) -> Option<String> {
70    let mut lines = contents.lines();
71    if lines.next()?.trim() != "---" {
72        return None;
73    }
74
75    for line in lines {
76        let line = line.trim();
77        if line == "---" {
78            break;
79        }
80        let Some(value) = line.strip_prefix("external_verify:") else {
81            continue;
82        };
83        let value = value.trim();
84        if value.is_empty() {
85            return None;
86        }
87        let command = if value.starts_with('"') {
88            serde_json::from_str::<String>(value).ok()
89        } else if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
90            Some(value[1..value.len() - 1].replace("''", "'"))
91        } else {
92            Some(value.to_owned())
93        };
94        return command.filter(|command| !command.trim().is_empty());
95    }
96    None
97}
98
99/// Run one explicitly operator-approved external verification command.
100///
101/// `sh -c` is intentional because probes may contain pipelines. The caller
102/// must source `cmd` from [`external_verify_commands`] and first require
103/// [`external_verification_approval`]. Spawn failures and non-zero exits fail
104/// closed.
105pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
106    std::process::Command::new("sh")
107        .arg("-c")
108        .arg(cmd)
109        .current_dir(project_root)
110        .output()
111        .map(|output| output.status.success())
112        .unwrap_or(false)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn write_plan(root: &std::path::Path, contents: &str) {
120        let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
121        std::fs::create_dir_all(&phase_dir).unwrap();
122        std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
123    }
124
125    #[test]
126    fn approval_parser_accepts_only_nonempty_json_command_arrays() {
127        assert_eq!(
128            parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
129            Some(vec!["test -f shipped".into(), "cargo test".into()])
130        );
131        for invalid in [
132            "",
133            "true",
134            "{}",
135            "[]",
136            r#"[""]"#,
137            r#"["   "]"#,
138            r#"["ok", 1]"#,
139        ] {
140            assert_eq!(
141                parse_external_verification_approval(invalid),
142                None,
143                "approval must fail closed for {invalid:?}"
144            );
145        }
146    }
147
148    #[test]
149    fn reads_external_verify_only_from_plan_frontmatter() {
150        let dir = tempfile::tempdir().unwrap();
151        write_plan(
152            dir.path(),
153            "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
154        );
155        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
156        std::fs::write(
157            dir.path().join(".devflow/phase-16-stdout"),
158            "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
159        )
160        .unwrap();
161
162        assert_eq!(
163            external_verify_commands(dir.path(), 16),
164            vec!["test -f shipped.txt"]
165        );
166    }
167
168    #[test]
169    fn ignores_external_verify_outside_frontmatter() {
170        let dir = tempfile::tempdir().unwrap();
171        write_plan(
172            dir.path(),
173            "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
174        );
175
176        assert!(external_verify_commands(dir.path(), 16).is_empty());
177    }
178
179    #[test]
180    fn ignores_empty_external_verify_commands() {
181        for value in [r#""""#, "''"] {
182            let dir = tempfile::tempdir().unwrap();
183            write_plan(
184                dir.path(),
185                &format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
186            );
187            assert!(
188                external_verify_commands(dir.path(), 16).is_empty(),
189                "empty command {value:?} must not count as affirmative verification"
190            );
191        }
192    }
193
194    #[test]
195    fn runs_probe_from_project_root_and_reports_exit_status() {
196        let dir = tempfile::tempdir().unwrap();
197        std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
198
199        assert!(run_external_verification("test -f shipped.txt", dir.path()));
200        assert!(!run_external_verification(
201            "test -f missing.txt",
202            dir.path()
203        ));
204    }
205}