1use std::path::{Path, PathBuf};
8
9pub const TRUST_EXTERNAL_VERIFY_ENV: &str = "DEVFLOW_TRUST_EXTERNAL_VERIFY";
11
12pub 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
28pub 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
99pub 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}