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/// Discover a phase's declared plan files: `.planning/phases/{NN}-*/{NN}-*-PLAN.md`,
29/// sorted. Returns an empty vec if the phase directory is missing or
30/// unreadable — never panics, never errors.
31///
32/// Shared by [`external_verify_commands`] and
33/// [`phase_has_blocking_human_checkpoint`] so PLAN.md discovery lives in
34/// exactly one place; a second, slightly-different implementation would
35/// drift the moment one is updated and the other isn't.
36pub fn phase_plan_files(project_root: &Path, phase: u32) -> Vec<PathBuf> {
37    let phases_dir = project_root.join(".planning/phases");
38    let phase_prefix = format!("{phase:02}-");
39    let plan_prefix = format!("{phase:02}-");
40    let mut plans = Vec::<PathBuf>::new();
41
42    let Ok(phase_entries) = std::fs::read_dir(phases_dir) else {
43        return Vec::new();
44    };
45    for phase_entry in phase_entries.flatten() {
46        if !phase_entry
47            .file_name()
48            .to_string_lossy()
49            .starts_with(&phase_prefix)
50        {
51            continue;
52        }
53        let Ok(plan_entries) = std::fs::read_dir(phase_entry.path()) else {
54            continue;
55        };
56        plans.extend(plan_entries.flatten().filter_map(|entry| {
57            let name = entry.file_name();
58            let name = name.to_string_lossy();
59            (name.starts_with(&plan_prefix) && name.ends_with("-PLAN.md")).then(|| entry.path())
60        }));
61    }
62    plans.sort();
63    plans
64}
65
66/// Return external verification commands declared by this phase's plans.
67///
68/// Only the first YAML frontmatter block is inspected. This intentionally
69/// small parser recognizes the scalar shape established by Phase 16:
70/// `external_verify: "command"` (single-quoted and unquoted scalars are also
71/// accepted). Runtime captures and agent output are never read here.
72pub fn external_verify_commands(project_root: &Path, phase: u32) -> Vec<String> {
73    phase_plan_files(project_root, phase)
74        .into_iter()
75        .filter_map(|path| std::fs::read_to_string(path).ok())
76        .filter_map(|contents| command_from_frontmatter(&contents))
77        .collect()
78}
79
80fn command_from_frontmatter(contents: &str) -> Option<String> {
81    let mut lines = contents.lines();
82    if lines.next()?.trim() != "---" {
83        return None;
84    }
85
86    for line in lines {
87        let line = line.trim();
88        if line == "---" {
89            break;
90        }
91        let Some(value) = line.strip_prefix("external_verify:") else {
92            continue;
93        };
94        let value = value.trim();
95        if value.is_empty() {
96            return None;
97        }
98        let command = if value.starts_with('"') {
99            serde_json::from_str::<String>(value).ok()
100        } else if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
101            Some(value[1..value.len() - 1].replace("''", "'"))
102        } else {
103            Some(value.to_owned())
104        };
105        return command.filter(|command| !command.trim().is_empty());
106    }
107    None
108}
109
110/// Return `true` if any plan declared for `phase` carries a task with the
111/// human-blocking checkpoint gate attribute.
112///
113/// Reads declared plan content only — never any runtime capture or agent
114/// output (D-02: no re-implementation of "what does the agent mean"). This
115/// is the PRIMARY gate for the auto-decide path added in plan 28-03: an
116/// agent cannot route itself into that path for a phase whose plans never
117/// declared a `gate="blocking-human"` checkpoint, because this scan runs
118/// first and is read from `.planning/phases/`. Those files are agent-writable
119/// during Code, but that is the SAME trust boundary [`external_verify_commands`]
120/// already documents and accepts — reused, not newly introduced.
121pub fn phase_has_blocking_human_checkpoint(project_root: &Path, phase: u32) -> bool {
122    const HUMAN_BLOCKING_GATE: &str = r#"gate="blocking-human""#;
123    phase_plan_files(project_root, phase)
124        .into_iter()
125        .filter_map(|path| std::fs::read_to_string(path).ok())
126        .any(|contents| contents.contains(HUMAN_BLOCKING_GATE))
127}
128
129/// Run one explicitly operator-approved external verification command.
130///
131/// `sh -c` is intentional because probes may contain pipelines. The caller
132/// must source `cmd` from [`external_verify_commands`] and first require
133/// [`external_verification_approval`]. Spawn failures and non-zero exits fail
134/// closed.
135pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
136    std::process::Command::new("sh")
137        .arg("-c")
138        .arg(cmd)
139        .current_dir(project_root)
140        .output()
141        .map(|output| output.status.success())
142        .unwrap_or(false)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn write_plan(root: &std::path::Path, contents: &str) {
150        let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
151        std::fs::create_dir_all(&phase_dir).unwrap();
152        std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
153    }
154
155    #[test]
156    fn approval_parser_accepts_only_nonempty_json_command_arrays() {
157        assert_eq!(
158            parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
159            Some(vec!["test -f shipped".into(), "cargo test".into()])
160        );
161        for invalid in [
162            "",
163            "true",
164            "{}",
165            "[]",
166            r#"[""]"#,
167            r#"["   "]"#,
168            r#"["ok", 1]"#,
169        ] {
170            assert_eq!(
171                parse_external_verification_approval(invalid),
172                None,
173                "approval must fail closed for {invalid:?}"
174            );
175        }
176    }
177
178    #[test]
179    fn reads_external_verify_only_from_plan_frontmatter() {
180        let dir = tempfile::tempdir().unwrap();
181        write_plan(
182            dir.path(),
183            "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
184        );
185        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
186        std::fs::write(
187            dir.path().join(".devflow/phase-16-stdout"),
188            "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
189        )
190        .unwrap();
191
192        assert_eq!(
193            external_verify_commands(dir.path(), 16),
194            vec!["test -f shipped.txt"]
195        );
196    }
197
198    #[test]
199    fn ignores_external_verify_outside_frontmatter() {
200        let dir = tempfile::tempdir().unwrap();
201        write_plan(
202            dir.path(),
203            "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
204        );
205
206        assert!(external_verify_commands(dir.path(), 16).is_empty());
207    }
208
209    #[test]
210    fn ignores_empty_external_verify_commands() {
211        for value in [r#""""#, "''"] {
212            let dir = tempfile::tempdir().unwrap();
213            write_plan(
214                dir.path(),
215                &format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
216            );
217            assert!(
218                external_verify_commands(dir.path(), 16).is_empty(),
219                "empty command {value:?} must not count as affirmative verification"
220            );
221        }
222    }
223
224    #[test]
225    fn runs_probe_from_project_root_and_reports_exit_status() {
226        let dir = tempfile::tempdir().unwrap();
227        std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
228
229        assert!(run_external_verification("test -f shipped.txt", dir.path()));
230        assert!(!run_external_verification(
231            "test -f missing.txt",
232            dir.path()
233        ));
234    }
235
236    // The gate value is assembled at runtime from this const, not written as a
237    // literal in fixture bodies below, so this test file itself never contains
238    // the raw `gate="blocking-human"` string (28-01 Task 2 action note).
239    const HUMAN_GATE_VALUE: &str = "blocking-human";
240    const PLAIN_GATE_VALUE: &str = "blocking";
241
242    fn write_phase_file(root: &std::path::Path, phase_dir: &str, file_name: &str, contents: &str) {
243        let dir = root.join(".planning/phases").join(phase_dir);
244        std::fs::create_dir_all(&dir).unwrap();
245        std::fs::write(dir.join(file_name), contents).unwrap();
246    }
247
248    #[test]
249    fn phase_has_blocking_human_checkpoint_detects_declared_gate() {
250        let dir = tempfile::tempdir().unwrap();
251        let body = format!(
252            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
253        );
254        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
255
256        assert!(phase_has_blocking_human_checkpoint(dir.path(), 91));
257    }
258
259    #[test]
260    fn phase_has_blocking_human_checkpoint_false_for_plain_blocking_gate() {
261        let dir = tempfile::tempdir().unwrap();
262        let body = format!(
263            "---\nphase: 91\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
264        );
265        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
266
267        assert!(
268            !phase_has_blocking_human_checkpoint(dir.path(), 91),
269            "the plain `blocking` gate (no -human suffix) must not match — Phase 26 near-miss distinction"
270        );
271    }
272
273    #[test]
274    fn phase_has_blocking_human_checkpoint_false_when_no_gate_attribute() {
275        let dir = tempfile::tempdir().unwrap();
276        write_phase_file(
277            dir.path(),
278            "91-probe",
279            "91-01-PLAN.md",
280            "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
281        );
282
283        assert!(!phase_has_blocking_human_checkpoint(dir.path(), 91));
284    }
285
286    #[test]
287    fn phase_has_blocking_human_checkpoint_false_for_missing_phase_directory() {
288        let dir = tempfile::tempdir().unwrap();
289
290        assert!(!phase_has_blocking_human_checkpoint(dir.path(), 404));
291    }
292
293    #[test]
294    fn phase_has_blocking_human_checkpoint_true_when_only_second_plan_carries_attribute() {
295        let dir = tempfile::tempdir().unwrap();
296        write_phase_file(
297            dir.path(),
298            "91-probe",
299            "91-01-PLAN.md",
300            "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
301        );
302        let body = format!(
303            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
304        );
305        write_phase_file(dir.path(), "91-probe", "91-02-PLAN.md", &body);
306
307        assert!(
308            phase_has_blocking_human_checkpoint(dir.path(), 91),
309            "every plan must be inspected, not just the first"
310        );
311    }
312
313    #[test]
314    fn phase_has_blocking_human_checkpoint_ignores_non_plan_files() {
315        let dir = tempfile::tempdir().unwrap();
316        let body = format!(
317            "---\nphase: 91\n---\n\nRecorded checkpoint gate=\"{HUMAN_GATE_VALUE}\" in the executor return.\n"
318        );
319        write_phase_file(dir.path(), "91-probe", "91-01-SUMMARY.md", &body);
320
321        assert!(
322            !phase_has_blocking_human_checkpoint(dir.path(), 91),
323            "only *-PLAN.md files are scanned, not SUMMARY/RESEARCH files"
324        );
325    }
326}