1mod 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
19pub const WORKFLOW_SCRIPT_TIMEOUT: Duration = Duration::from_secs(30);
21
22pub const NEXT_WIZARD_MAX_DEPTH: u32 = 16;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Allowlist {
28 pub share_root: PathBuf,
30 pub cwd: PathBuf,
32 pub wizard_dir: PathBuf,
34}
35
36impl Allowlist {
37 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#[derive(Debug, Clone)]
117pub struct WorkflowRunner {
118 pub allowlist: Allowlist,
120 pub timeout: Duration,
122 pub extra_env: Vec<(OsString, OsString)>,
124}
125
126impl WorkflowRunner {
127 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 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
216pub 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#[derive(Debug)]
233pub enum WorkflowError {
234 PathDenied {
236 path: PathBuf,
238 },
239 Timeout {
241 stderr_tail: String,
243 },
244 NonZero {
246 status: i32,
248 stderr_tail: String,
250 },
251 InvalidStdout {
253 cause: String,
255 },
256 ChainDepth {
258 max: u32,
260 },
261 Resolve {
263 path: String,
265 cause: String,
267 },
268 MissingPython3,
270 Merge {
272 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 #[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 #[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 #[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
387fn 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}