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.
121///
122/// **The CALLER is responsible for passing the root the phase's plans actually
123/// live under** — the execution root (the worktree) in worktree mode, the
124/// project root otherwise. `.planning/` is tracked content, so an in-flight
125/// phase's `{N}-PLAN.md` sits on `feature/phase-{N}` inside the worktree and
126/// is absent from the main checkout; a caller passing the main checkout gets a
127/// silent `false` rather than an error (999.76, ROADMAP criterion 6). The
128/// caller that does this correctly is `pipeline_launch.rs`'s
129/// `Action::GateReview` arm, which resolves `state.worktree_path` first.
130pub fn phase_has_blocking_human_checkpoint(project_root: &Path, phase: u32) -> bool {
131    const HUMAN_BLOCKING_GATE: &str = r#"gate="blocking-human""#;
132    phase_plan_files(project_root, phase)
133        .into_iter()
134        .filter_map(|path| std::fs::read_to_string(path).ok())
135        .any(|contents| contents.contains(HUMAN_BLOCKING_GATE))
136}
137
138/// Run one explicitly operator-approved external verification command.
139///
140/// `sh -c` is intentional because probes may contain pipelines. The caller
141/// must source `cmd` from [`external_verify_commands`] and first require
142/// [`external_verification_approval`]. Spawn failures and non-zero exits fail
143/// closed.
144pub fn run_external_verification(cmd: &str, project_root: &Path) -> bool {
145    std::process::Command::new("sh")
146        .arg("-c")
147        .arg(cmd)
148        .current_dir(project_root)
149        .output()
150        .map(|output| output.status.success())
151        .unwrap_or(false)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn write_plan(root: &std::path::Path, contents: &str) {
159        let phase_dir = root.join(".planning/phases/16-pipeline-reliability-hardening");
160        std::fs::create_dir_all(&phase_dir).unwrap();
161        std::fs::write(phase_dir.join("16-03-PLAN.md"), contents).unwrap();
162    }
163
164    #[test]
165    fn approval_parser_accepts_only_nonempty_json_command_arrays() {
166        assert_eq!(
167            parse_external_verification_approval(r#"["test -f shipped", "cargo test"]"#),
168            Some(vec!["test -f shipped".into(), "cargo test".into()])
169        );
170        for invalid in [
171            "",
172            "true",
173            "{}",
174            "[]",
175            r#"[""]"#,
176            r#"["   "]"#,
177            r#"["ok", 1]"#,
178        ] {
179            assert_eq!(
180                parse_external_verification_approval(invalid),
181                None,
182                "approval must fail closed for {invalid:?}"
183            );
184        }
185    }
186
187    #[test]
188    fn reads_external_verify_only_from_plan_frontmatter() {
189        let dir = tempfile::tempdir().unwrap();
190        write_plan(
191            dir.path(),
192            "---\nphase: 16\nexternal_verify: \"test -f shipped.txt\"\n---\n\n# Plan\n",
193        );
194        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
195        std::fs::write(
196            dir.path().join(".devflow/phase-16-stdout"),
197            "external_verify: \"touch agent-controlled\"\nDEVFLOW_RESULT: {\"status\":\"success\"}\n",
198        )
199        .unwrap();
200
201        assert_eq!(
202            external_verify_commands(dir.path(), 16),
203            vec!["test -f shipped.txt"]
204        );
205    }
206
207    #[test]
208    fn ignores_external_verify_outside_frontmatter() {
209        let dir = tempfile::tempdir().unwrap();
210        write_plan(
211            dir.path(),
212            "---\nphase: 16\n---\n\nexternal_verify: \"false\"\n",
213        );
214
215        assert!(external_verify_commands(dir.path(), 16).is_empty());
216    }
217
218    #[test]
219    fn ignores_empty_external_verify_commands() {
220        for value in [r#""""#, "''"] {
221            let dir = tempfile::tempdir().unwrap();
222            write_plan(
223                dir.path(),
224                &format!("---\nphase: 16\nexternal_verify: {value}\n---\n"),
225            );
226            assert!(
227                external_verify_commands(dir.path(), 16).is_empty(),
228                "empty command {value:?} must not count as affirmative verification"
229            );
230        }
231    }
232
233    #[test]
234    fn runs_probe_from_project_root_and_reports_exit_status() {
235        let dir = tempfile::tempdir().unwrap();
236        std::fs::write(dir.path().join("shipped.txt"), "ok").unwrap();
237
238        assert!(run_external_verification("test -f shipped.txt", dir.path()));
239        assert!(!run_external_verification(
240            "test -f missing.txt",
241            dir.path()
242        ));
243    }
244
245    // The gate value is assembled at runtime from this const, not written as a
246    // literal in fixture bodies below, so this test file itself never contains
247    // the raw `gate="blocking-human"` string (28-01 Task 2 action note).
248    const HUMAN_GATE_VALUE: &str = "blocking-human";
249    const PLAIN_GATE_VALUE: &str = "blocking";
250
251    fn write_phase_file(root: &std::path::Path, phase_dir: &str, file_name: &str, contents: &str) {
252        let dir = root.join(".planning/phases").join(phase_dir);
253        std::fs::create_dir_all(&dir).unwrap();
254        std::fs::write(dir.join(file_name), contents).unwrap();
255    }
256
257    #[test]
258    fn phase_has_blocking_human_checkpoint_detects_declared_gate() {
259        let dir = tempfile::tempdir().unwrap();
260        let body = format!(
261            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
262        );
263        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
264
265        assert!(phase_has_blocking_human_checkpoint(dir.path(), 91));
266    }
267
268    #[test]
269    fn phase_has_blocking_human_checkpoint_false_for_plain_blocking_gate() {
270        let dir = tempfile::tempdir().unwrap();
271        let body = format!(
272            "---\nphase: 91\n---\n\n<task type=\"checkpoint:decision\" gate=\"{PLAIN_GATE_VALUE}\">\n</task>\n"
273        );
274        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
275
276        assert!(
277            !phase_has_blocking_human_checkpoint(dir.path(), 91),
278            "the plain `blocking` gate (no -human suffix) must not match — Phase 26 near-miss distinction"
279        );
280    }
281
282    #[test]
283    fn phase_has_blocking_human_checkpoint_false_when_no_gate_attribute() {
284        let dir = tempfile::tempdir().unwrap();
285        write_phase_file(
286            dir.path(),
287            "91-probe",
288            "91-01-PLAN.md",
289            "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
290        );
291
292        assert!(!phase_has_blocking_human_checkpoint(dir.path(), 91));
293    }
294
295    #[test]
296    fn phase_has_blocking_human_checkpoint_false_for_missing_phase_directory() {
297        let dir = tempfile::tempdir().unwrap();
298
299        assert!(!phase_has_blocking_human_checkpoint(dir.path(), 404));
300    }
301
302    #[test]
303    fn phase_has_blocking_human_checkpoint_true_when_only_second_plan_carries_attribute() {
304        let dir = tempfile::tempdir().unwrap();
305        write_phase_file(
306            dir.path(),
307            "91-probe",
308            "91-01-PLAN.md",
309            "---\nphase: 91\n---\n\n<task type=\"auto\">\n</task>\n",
310        );
311        let body = format!(
312            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
313        );
314        write_phase_file(dir.path(), "91-probe", "91-02-PLAN.md", &body);
315
316        assert!(
317            phase_has_blocking_human_checkpoint(dir.path(), 91),
318            "every plan must be inspected, not just the first"
319        );
320    }
321
322    #[test]
323    fn phase_has_blocking_human_checkpoint_ignores_non_plan_files() {
324        let dir = tempfile::tempdir().unwrap();
325        let body = format!(
326            "---\nphase: 91\n---\n\nRecorded checkpoint gate=\"{HUMAN_GATE_VALUE}\" in the executor return.\n"
327        );
328        write_phase_file(dir.path(), "91-probe", "91-01-SUMMARY.md", &body);
329
330        assert!(
331            !phase_has_blocking_human_checkpoint(dir.path(), 91),
332            "only *-PLAN.md files are scanned, not SUMMARY/RESEARCH files"
333        );
334    }
335
336    /// 999.76 (ROADMAP criterion 6), the second call site.
337    ///
338    /// This function reads whatever root it is handed, so the CALLER decides
339    /// whether it can see the phase's plans at all. `pipeline_launch.rs`'s
340    /// `Action::GateReview` arm passed `project_root` unconditionally; in
341    /// worktree mode the phase's `{N}-PLAN.md` lives on `feature/phase-{N}`
342    /// inside the worktree and is absent from the main checkout, so this
343    /// returned `false` and the plan-28-03 checkpoint auto-decide path was
344    /// silently dead for the phase's whole duration.
345    ///
346    /// This test and its mirror below pin the property that makes the caller's
347    /// choice matter: the answer DEPENDS on the root. Each carries the
348    /// opposite-root assertion, because a pair that returned `true` for both
349    /// roots would only be measuring that a PLAN exists somewhere.
350    #[test]
351    fn phase_has_blocking_human_checkpoint_reads_the_execution_root_in_worktree_mode() {
352        let dir = tempfile::tempdir().unwrap();
353        let worktree = dir.path().join("phase-worktree");
354        std::fs::create_dir_all(&worktree).unwrap();
355        let body = format!(
356            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
357        );
358        // The PLAN exists ONLY inside the worktree — the project root's own
359        // `.planning/phases/` is deliberately never created.
360        write_phase_file(&worktree, "91-probe", "91-01-PLAN.md", &body);
361
362        assert!(
363            phase_has_blocking_human_checkpoint(&worktree, 91),
364            "the execution root holds the PLAN, so the declaration must be found"
365        );
366        assert!(
367            !phase_has_blocking_human_checkpoint(dir.path(), 91),
368            "opposite-result case: the project root has no PLAN and must return false — \
369             if both roots returned true, this pair would be measuring the presence of a \
370             file somewhere rather than which root is read"
371        );
372    }
373
374    /// The main-checkout mirror of the test above: with no worktree the two
375    /// roots coincide, so 999.76's call-site change leaves this path untouched.
376    #[test]
377    fn phase_has_blocking_human_checkpoint_still_reads_the_project_root_without_a_worktree() {
378        let dir = tempfile::tempdir().unwrap();
379        let empty_sibling = dir.path().join("phase-worktree");
380        std::fs::create_dir_all(&empty_sibling).unwrap();
381        let body = format!(
382            "---\nphase: 91\n---\n\n<task type=\"checkpoint:human-verify\" gate=\"{HUMAN_GATE_VALUE}\">\n</task>\n"
383        );
384        write_phase_file(dir.path(), "91-probe", "91-01-PLAN.md", &body);
385
386        assert!(
387            phase_has_blocking_human_checkpoint(dir.path(), 91),
388            "without a worktree the execution root IS the project root"
389        );
390        assert!(
391            !phase_has_blocking_human_checkpoint(&empty_sibling, 91),
392            "opposite-result case: a root without the PLAN must return false, so the \
393             assertion above is about which root is read and not about the file existing"
394        );
395    }
396}