Skip to main content

wyvern/workflow/
mod.rs

1//! Wizard workflow hooks and `next_wizard` chain loop (REQ-0124–0126).
2//!
3//! Spawn, timeout, and stderr-tail go through [`crate::extensions::run_script`]
4//! only — this module must not call `std::process::Command::new` (ADR-0023).
5
6mod chain;
7
8use std::ffi::{OsStr, OsString};
9use std::path::{Component, Path, PathBuf};
10use std::time::Duration;
11
12use serde_json::Value;
13
14use crate::extensions::{binary_on_path, run_script, ScriptError, ScriptRequest};
15
16#[doc(inline)]
17pub use chain::{merge_wizard_config, resolve_next_wizard, NextInvocation};
18
19/// Timeout for every workflow pre/post script (REQ-0124 / REQ-0125).
20pub const WORKFLOW_SCRIPT_TIMEOUT: Duration = Duration::from_secs(30);
21
22/// Maximum wizard sessions in one `next_wizard` chain (REQ-0126).
23pub const NEXT_WIZARD_MAX_DEPTH: u32 = 16;
24
25/// Allowlisted roots for workflow script and `next_wizard` paths.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Allowlist {
28    /// Resolved `{wyvern_share}` directory.
29    pub share_root: PathBuf,
30    /// Process working directory.
31    pub cwd: PathBuf,
32    /// Directory of the current `wizard.json`.
33    pub wizard_dir: PathBuf,
34}
35
36impl Allowlist {
37    /// Expand `{wyvern_share}`, canonicalize, and reject `..` / symlink escape.
38    ///
39    /// Relative paths try `{wyvern_share}`, then cwd, then the current wizard
40    /// directory.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`WorkflowError::PathDenied`] when the resolved path escapes the
45    /// allowlist, or [`WorkflowError::Resolve`] when expansion / lookup fails.
46    pub fn resolve_allowed(&self, raw: &str) -> Result<PathBuf, WorkflowError> {
47        let expanded = expand_wyvern_share(raw, &self.share_root);
48        let candidate = PathBuf::from(&expanded);
49        let roots = self.canonical_roots();
50
51        let tries: Vec<PathBuf> = if candidate.is_absolute() {
52            vec![candidate]
53        } else {
54            vec![
55                self.share_root.join(&expanded),
56                self.cwd.join(&expanded),
57                self.wizard_dir.join(&expanded),
58            ]
59        };
60
61        let mut saw_escape = false;
62        for try_path in tries {
63            let lexical = lexical_normalize(&try_path);
64            if !is_under_any(&lexical, &self.lexical_roots()) && !is_under_any(&lexical, &roots) {
65                saw_escape = true;
66                continue;
67            }
68            match std::fs::canonicalize(&try_path) {
69                Ok(canon) => {
70                    if is_under_any(&canon, &roots) {
71                        return Ok(canon);
72                    }
73                    saw_escape = true;
74                }
75                Err(_) => {
76                    if is_under_any(&lexical, &self.lexical_roots()) {
77                        return Err(WorkflowError::Resolve {
78                            path: raw.to_string(),
79                            cause: format!("path does not exist: {}", try_path.display()),
80                        });
81                    }
82                    saw_escape = true;
83                }
84            }
85        }
86
87        if saw_escape {
88            Err(WorkflowError::PathDenied {
89                path: PathBuf::from(expanded),
90            })
91        } else {
92            Err(WorkflowError::Resolve {
93                path: raw.to_string(),
94                cause: "could not resolve path against share, cwd, or wizard directory".into(),
95            })
96        }
97    }
98
99    fn canonical_roots(&self) -> Vec<PathBuf> {
100        [&self.share_root, &self.cwd, &self.wizard_dir]
101            .into_iter()
102            .filter_map(|p| std::fs::canonicalize(p).ok())
103            .collect()
104    }
105
106    fn lexical_roots(&self) -> Vec<PathBuf> {
107        vec![
108            lexical_normalize(&self.share_root),
109            lexical_normalize(&self.cwd),
110            lexical_normalize(&self.wizard_dir),
111        ]
112    }
113}
114
115/// Runs `workflow.pre` / `workflow.post` through Phase F preexec helpers.
116#[derive(Debug, Clone)]
117pub struct WorkflowRunner {
118    /// Path allowlist for this hop.
119    pub allowlist: Allowlist,
120    /// Script timeout (normally [`WORKFLOW_SCRIPT_TIMEOUT`]).
121    pub timeout: Duration,
122    /// Extra child env merged after the standard workflow env (tests: `WYVERN_HOME`).
123    pub extra_env: Vec<(OsString, OsString)>,
124}
125
126impl WorkflowRunner {
127    /// Run `spec.pre` if present and deep-merge `config_patch` into `config`.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`WorkflowError`] on allowlist, spawn, timeout, nonzero, or
132    /// invalid stdout.
133    pub fn run_pre(
134        &self,
135        spec: &wyvern_schema::WorkflowSpec,
136        config: &mut Value,
137        dry_run: bool,
138    ) -> Result<(), WorkflowError> {
139        let Some(raw) = spec.pre.as_deref() else {
140            return Ok(());
141        };
142        let stdout = self.spawn_script(raw, None, true, dry_run)?;
143        let patch = parse_config_patch(&stdout)?;
144        *config = merge_wizard_config(
145            config.clone(),
146            Value::Object(Default::default()),
147            Some(patch),
148        )?;
149        Ok(())
150    }
151
152    /// Run `spec.post` if present, sending `finish` JSON on stdin.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`WorkflowError`] on allowlist, spawn, timeout, or nonzero exit.
157    pub fn run_post(
158        &self,
159        spec: &wyvern_schema::WorkflowSpec,
160        finish: &Value,
161        dry_run: bool,
162    ) -> Result<(), WorkflowError> {
163        let Some(raw) = spec.post.as_deref() else {
164            return Ok(());
165        };
166        let stdin = serde_json::to_vec(finish).map_err(|err| WorkflowError::InvalidStdout {
167            cause: format!("could not serialize finish JSON for post stdin: {err}"),
168        })?;
169        self.spawn_script(raw, Some(stdin), false, dry_run)?;
170        Ok(())
171    }
172
173    fn spawn_script(
174        &self,
175        raw: &str,
176        stdin: Option<Vec<u8>>,
177        capture_stdout: bool,
178        dry_run: bool,
179    ) -> Result<String, WorkflowError> {
180        let canonical = self.allowlist.resolve_allowed(raw)?;
181        let mut argv = script_argv(&canonical)?;
182        if dry_run {
183            argv.push(OsString::from("--dry-run"));
184        }
185        let program = argv
186            .first()
187            .cloned()
188            .ok_or_else(|| WorkflowError::Resolve {
189                path: raw.to_string(),
190                cause: "script argv was empty".into(),
191            })?;
192        let args = argv.into_iter().skip(1).collect::<Vec<_>>();
193        let mut extra_env = workflow_env(&self.allowlist)?;
194        extra_env.extend(self.extra_env.iter().cloned());
195        let request = ScriptRequest {
196            program,
197            args,
198            cwd: Some(self.allowlist.cwd.clone()),
199            extra_env,
200            stdin,
201            capture_stdout,
202            timeout: self.timeout,
203            process_group: true,
204        };
205        let output = run_script(&request).map_err(map_script_error)?;
206        if !output.status.success() {
207            return Err(WorkflowError::NonZero {
208                status: output.status.code().unwrap_or(1),
209                stderr_tail: output.stderr_tail,
210            });
211        }
212        Ok(output.stdout.unwrap_or_default())
213    }
214}
215
216/// Fail when `hop` exceeds [`NEXT_WIZARD_MAX_DEPTH`].
217///
218/// # Errors
219///
220/// Returns [`WorkflowError::ChainDepth`] when `hop` is 17 or greater.
221pub fn check_chain_depth(hop: u32) -> Result<(), WorkflowError> {
222    if hop > NEXT_WIZARD_MAX_DEPTH {
223        Err(WorkflowError::ChainDepth {
224            max: NEXT_WIZARD_MAX_DEPTH,
225        })
226    } else {
227        Ok(())
228    }
229}
230
231/// Workflow / chain failure (stderr via [`wyvern_schema::ErrorCode::WorkflowError`]).
232#[derive(Debug)]
233pub enum WorkflowError {
234    /// Path escaped `{wyvern_share}`, cwd, or the current wizard directory.
235    PathDenied {
236        /// Offending path after expansion.
237        path: PathBuf,
238    },
239    /// Script exceeded 30s.
240    Timeout {
241        /// Last 4 KiB of child stderr collected before kill.
242        stderr_tail: String,
243    },
244    /// Script exit status was not zero.
245    NonZero {
246        /// Process exit code (or `1` when killed by signal).
247        status: i32,
248        /// Last 4 KiB of child stderr.
249        stderr_tail: String,
250    },
251    /// Pre stdout was not one JSON object with an object `config_patch`.
252    InvalidStdout {
253        /// Parse / shape failure detail.
254        cause: String,
255    },
256    /// A 17th hop was requested.
257    ChainDepth {
258        /// Configured maximum (`16`).
259        max: u32,
260    },
261    /// `{wyvern_share}` / relative path could not be resolved.
262    Resolve {
263        /// Original path string.
264        path: String,
265        /// Why resolution failed.
266        cause: String,
267    },
268    /// `.py` script and `python3` is not on PATH.
269    MissingPython3,
270    /// `input` or `config_patch` was not a JSON object.
271    Merge {
272        /// Merge failure detail.
273        cause: String,
274    },
275}
276
277impl std::fmt::Display for WorkflowError {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        match self {
280            Self::PathDenied { path } => {
281                write!(f, "workflow path denied: {}", path.display())
282            }
283            Self::Timeout { stderr_tail } => {
284                if stderr_tail.is_empty() {
285                    f.write_str("workflow script timed out after 30s")
286                } else {
287                    write!(f, "workflow script timed out after 30s: {stderr_tail}")
288                }
289            }
290            Self::NonZero {
291                status,
292                stderr_tail,
293            } => {
294                if stderr_tail.is_empty() {
295                    write!(f, "workflow script exited with status {status}")
296                } else {
297                    write!(
298                        f,
299                        "workflow script exited with status {status}: {stderr_tail}"
300                    )
301                }
302            }
303            Self::InvalidStdout { cause } => write!(f, "invalid workflow pre stdout: {cause}"),
304            Self::ChainDepth { max } => {
305                write!(f, "next_wizard chain exceeded maximum depth of {max}")
306            }
307            Self::Resolve { path, cause } => {
308                write!(f, "could not resolve workflow path '{path}': {cause}")
309            }
310            Self::MissingPython3 => f.write_str("python3 is required to run .py workflow scripts"),
311            Self::Merge { cause } => write!(f, "workflow config merge failed: {cause}"),
312        }
313    }
314}
315
316impl std::error::Error for WorkflowError {}
317
318impl WorkflowError {
319    /// Stable recovery steps for stderr JSON (RBP-001).
320    #[must_use]
321    pub fn recovery(&self) -> Vec<String> {
322        match self {
323            Self::PathDenied { .. } => vec![
324                "Use a path under {wyvern_share}, the process cwd, or the current wizard.json directory".into(),
325            ],
326            Self::Timeout { .. } => vec![
327                "Shorten the workflow script or raise the timeout only via a later ADR".into(),
328            ],
329            Self::NonZero { .. } => vec![
330                "Fix the script; stderr_tail is in the JSON cause".into(),
331            ],
332            Self::InvalidStdout { .. } => vec![
333                r#"Print { "config_patch": { ... } } only"#.into(),
334            ],
335            Self::ChainDepth { max } => vec![format!("Keep chains ≤ {max}")],
336            Self::Resolve { .. } => vec!["Fix the path string".into()],
337            Self::MissingPython3 => vec!["Install Python 3".into()],
338            Self::Merge { .. } => vec!["Pass JSON objects for input and config_patch".into()],
339        }
340    }
341
342    /// Short cause string for the stderr envelope.
343    #[must_use]
344    pub fn cause(&self) -> String {
345        match self {
346            Self::PathDenied { path } => {
347                format!("path escaped the workflow allowlist: {}", path.display())
348            }
349            Self::Timeout { stderr_tail } => {
350                if stderr_tail.is_empty() {
351                    "script exceeded 30s".into()
352                } else {
353                    format!("script exceeded 30s: {stderr_tail}")
354                }
355            }
356            Self::NonZero { stderr_tail, .. } => {
357                if stderr_tail.is_empty() {
358                    "script exit was not 0".into()
359                } else {
360                    stderr_tail.clone()
361                }
362            }
363            Self::InvalidStdout { cause } => cause.clone(),
364            Self::ChainDepth { max } => format!("17th hop requested; max is {max}"),
365            Self::Resolve { cause, .. } => cause.clone(),
366            Self::MissingPython3 => "python3 not found on PATH".into(),
367            Self::Merge { cause } => cause.clone(),
368        }
369    }
370
371    /// Stable sub-discriminator for machine branching under `WORKFLOW_ERROR`.
372    #[must_use]
373    pub fn subcode(&self) -> &'static str {
374        match self {
375            Self::PathDenied { .. } => "path_denied",
376            Self::Timeout { .. } => "timeout",
377            Self::NonZero { .. } => "nonzero",
378            Self::InvalidStdout { .. } => "invalid_stdout",
379            Self::ChainDepth { .. } => "chain_depth",
380            Self::Resolve { .. } => "resolve",
381            Self::MissingPython3 => "missing_python3",
382            Self::Merge { .. } => "merge",
383        }
384    }
385}
386
387/// Build argv for a canonical script path. `.py` → `python3`/`py`/`python` `<path>`.
388fn script_argv(canonical: &Path) -> Result<Vec<OsString>, WorkflowError> {
389    if canonical.extension() == Some(OsStr::new("py")) {
390        let python = resolve_python_program().ok_or(WorkflowError::MissingPython3)?;
391        Ok(vec![python, canonical.as_os_str().to_os_string()])
392    } else {
393        Ok(vec![canonical.as_os_str().to_os_string()])
394    }
395}
396
397fn resolve_python_program() -> Option<OsString> {
398    for name in ["python3", "py", "python"] {
399        if binary_on_path(name) {
400            return Some(OsString::from(name));
401        }
402    }
403    None
404}
405
406fn workflow_env(allowlist: &Allowlist) -> Result<Vec<(OsString, OsString)>, WorkflowError> {
407    let wyvern_bin = resolve_wyvern_bin();
408    let repo_root = std::env::var_os("WYVERN_REPO_ROOT")
409        .unwrap_or_else(|| allowlist.cwd.clone().into_os_string());
410    Ok(vec![
411        (
412            OsString::from("WYVERN_SHARE"),
413            allowlist.share_root.clone().into_os_string(),
414        ),
415        (OsString::from("WYVERN_REPO_ROOT"), repo_root),
416        (OsString::from("WYVERN_BIN"), wyvern_bin),
417    ])
418}
419
420fn resolve_wyvern_bin() -> OsString {
421    match std::env::current_exe() {
422        Ok(exe) => std::fs::canonicalize(&exe).unwrap_or(exe).into_os_string(),
423        Err(_) => OsString::from("wyvern"),
424    }
425}
426
427fn expand_wyvern_share(raw: &str, share_root: &Path) -> String {
428    raw.replace("{wyvern_share}", &share_root.to_string_lossy())
429}
430
431fn lexical_normalize(path: &Path) -> PathBuf {
432    let mut out = PathBuf::new();
433    for component in path.components() {
434        match component {
435            Component::CurDir => {}
436            Component::ParentDir => {
437                let _ = out.pop();
438            }
439            other => out.push(other.as_os_str()),
440        }
441    }
442    out
443}
444
445fn is_under_any(path: &Path, roots: &[PathBuf]) -> bool {
446    roots.iter().any(|root| path.starts_with(root))
447}
448
449fn parse_config_patch(stdout: &str) -> Result<Value, WorkflowError> {
450    let trimmed = stdout.trim();
451    let value: Value =
452        serde_json::from_str(trimmed).map_err(|err| WorkflowError::InvalidStdout {
453            cause: format!("pre stdout is not JSON: {err}"),
454        })?;
455    let obj = value
456        .as_object()
457        .ok_or_else(|| WorkflowError::InvalidStdout {
458            cause: "pre stdout must be one JSON object".into(),
459        })?;
460    if obj.len() != 1 || !obj.contains_key("config_patch") {
461        return Err(WorkflowError::InvalidStdout {
462            cause: "pre stdout must be an object with only config_patch".into(),
463        });
464    }
465    let patch = obj
466        .get("config_patch")
467        .cloned()
468        .ok_or_else(|| WorkflowError::InvalidStdout {
469            cause: "pre stdout missing config_patch".into(),
470        })?;
471    if !patch.is_object() {
472        return Err(WorkflowError::InvalidStdout {
473            cause: "config_patch must be a JSON object".into(),
474        });
475    }
476    Ok(patch)
477}
478
479fn map_script_error(err: ScriptError) -> WorkflowError {
480    match err {
481        ScriptError::Timeout { stderr_tail, .. } => WorkflowError::Timeout { stderr_tail },
482        ScriptError::SpawnNotFound { cmd, .. }
483            if matches!(cmd.as_str(), "python3" | "py" | "python") =>
484        {
485            WorkflowError::MissingPython3
486        }
487        ScriptError::SpawnNotFound { cmd, source } | ScriptError::Spawn { cmd, source } => {
488            WorkflowError::Resolve {
489                path: cmd,
490                cause: source.to_string(),
491            }
492        }
493        ScriptError::Wait { cmd, source } => WorkflowError::Resolve {
494            path: cmd,
495            cause: source.to_string(),
496        },
497        ScriptError::Stdout { cause, .. } => WorkflowError::InvalidStdout { cause },
498        ScriptError::Thread { message } => WorkflowError::Resolve {
499            path: String::new(),
500            cause: message,
501        },
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use serde_json::json;
509
510    fn temp_allowlist() -> (tempfile::TempDir, Allowlist) {
511        let tmp = tempfile::tempdir().expect("tmp");
512        let share = tmp.path().join("share");
513        let cwd = tmp.path().join("cwd");
514        let wizard = tmp.path().join("wizard");
515        std::fs::create_dir_all(&share).unwrap();
516        std::fs::create_dir_all(&cwd).unwrap();
517        std::fs::create_dir_all(&wizard).unwrap();
518        let allowlist = Allowlist {
519            share_root: share,
520            cwd,
521            wizard_dir: wizard,
522        };
523        (tmp, allowlist)
524    }
525
526    #[test]
527    fn resolve_allowed_rejects_escape() {
528        let (_tmp, allow) = temp_allowlist();
529        let err = allow
530            .resolve_allowed("../../../../etc/passwd")
531            .expect_err("escape");
532        assert!(matches!(err, WorkflowError::PathDenied { .. }), "{err:?}");
533    }
534
535    #[test]
536    fn check_chain_depth_rejects_seventeenth_hop() {
537        check_chain_depth(16).expect("16 ok");
538        let err = check_chain_depth(17).expect_err("17");
539        assert!(matches!(
540            err,
541            WorkflowError::ChainDepth {
542                max: NEXT_WIZARD_MAX_DEPTH
543            }
544        ));
545    }
546
547    #[test]
548    fn parse_config_patch_requires_object() {
549        let err = parse_config_patch("[]").expect_err("array");
550        assert!(matches!(err, WorkflowError::InvalidStdout { .. }));
551        let patch = parse_config_patch(r#"{"config_patch":{"k":1}}"#).expect("ok");
552        assert_eq!(patch, json!({"k": 1}));
553    }
554
555    #[test]
556    fn timeout_cause_includes_stderr_tail() {
557        let err = WorkflowError::Timeout {
558            stderr_tail: "still running child".into(),
559        };
560        assert_eq!(err.subcode(), "timeout");
561        assert!(err.cause().contains("still running child"));
562        assert!(err.to_string().contains("still running child"));
563    }
564}