tatara_lisp_script/stdlib/process.rs
1//! Process / shell integration.
2//!
3//! (exec-check CMD ARG…) → 0 on success, non-zero exit code otherwise
4//! Streams stdin/stdout/stderr to the parent.
5//! (exec-capture CMD ARG…) → the CAPTURE RECORD (below)
6//! Captures stdout + stderr, exposes exit code.
7//! (exec-ok? CMD ARG…) → bool; true iff exit code is 0
8//! (sh-exec STR) → convenience: run STR through `sh -c`
9//! returning the capture-form result
10//!
11//! Credential-carrying variants. Use these instead of putting a secret in an
12//! argument: argv is world-readable from the process table, and in CI it is
13//! readable by co-tenant steps and sibling containers.
14//!
15//! (exec-with-stdin IN CMD ARG…) → capture form; IN is written to the
16//! child's stdin. The `--password-stdin`
17//! shape that docker / helm / skopeo / gh
18//! already support. Preferred.
19//! (exec-with-env ENV CMD ARG…) → capture form; ENV is an alist of
20//! (KEY VALUE) pairs set for the child only.
21//! For tools with no stdin form. Weaker than
22//! stdin — the environment is readable by the
23//! same uid — but far stronger than argv.
24//!
25//! No implicit shell interpolation. Arguments are passed literally to
26//! the underlying process; no glob / word-splitting / $VAR substitution.
27//! Scripts that want shell features use `sh-exec` explicitly.
28//!
29//! ── THE CAPTURE RECORD ───────────────────────────────────────────────────
30//! Every capture-form primitive above returns the SAME eight-field alist, so a
31//! caller never has to know which form produced it:
32//!
33//! (:status N) exit code, or -1 when killed by a signal
34//! (:stdout "…") captured stdout
35//! (:stderr "…") captured stderr
36//! (:argv ("cmd" "a")) the argv LIST, exact and unsplit
37//! (:program "cmd") the program as ASKED FOR
38//! (:resolved "/nix/…") the program as RESOLVED — "" when PATH lookup failed
39//! (:cwd "/…") the directory the child inherited
40//! (:duration-ms N) wall-clock milliseconds
41//!
42//! The last five landed 2026-08-17 and are not decoration. A deshellify port's
43//! correctness argument is always "the new thing does what the old thing did",
44//! which is a COMPARISON — and you cannot compare an invocation you did not
45//! record. Concretely: `:resolved` answers "did the RIGHT tool run" (the
46//! silent-PATH-fallback class — `Command::new("kubectl")` runs whatever PATH
47//! found first, and 218 such bare spawns were measured in pleme-io/forge);
48//! `:cwd` distinguishes a write to a flake's read-only /nix/store source copy
49//! from one to the work tree, which otherwise surfaces only as a bare
50//! `Permission denied (os error 13)`; `:duration-ms` separates "failed" from
51//! "hung", which a status alone cannot.
52//!
53//! `:argv` is a LIST and never a joined string, because re-quoting a command
54//! changes it — a single string cannot be compared against what was run.
55//!
56//! ADDITIVE by construction: every `alist-get` consumer of the record is
57//! unaffected, and nothing in the tree asserted the record's length.
58//!
59//! **The record's readers are native as of 2026-09-15** — `status-of`,
60//! `stdout-of`, `stderr-of`, installed by `install_capture_readers` below.
61//! Before that, this paragraph named them as consumers while they were NOT
62//! builtins, and a script trusting it died with `unbound symbol` at runtime.
63//! A doc comment that names a symbol is not a binding; see that function for
64//! the 40-file measurement that licensed making them one.
65//!
66//! Canonical technique: pleme-io/docs/controlled-subprocess.md (rung 2).
67
68use std::process::{Command, Stdio};
69use std::sync::Arc;
70
71use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
72
73use crate::script_ctx::ScriptCtx;
74use crate::stdlib::env::str_arg;
75
76pub fn install(interp: &mut Interpreter<ScriptCtx>) {
77 interp.register_fn(
78 "exec-check",
79 Arity::AtLeast(1),
80 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
81 let (cmd, rest) = split_cmd(args, "exec-check", sp)?;
82 let status = Command::new(&*cmd)
83 .args(rest.iter().map(|s| s.as_ref()))
84 .stdin(Stdio::inherit())
85 .stdout(Stdio::inherit())
86 .stderr(Stdio::inherit())
87 .status()
88 .map_err(|e| EvalError::native_fn("exec-check", e.to_string(), sp))?;
89 Ok(Value::Int(status.code().unwrap_or(-1) as i64))
90 },
91 );
92
93 interp.register_fn(
94 "exec-ok?",
95 Arity::AtLeast(1),
96 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
97 let (cmd, rest) = split_cmd(args, "exec-ok?", sp)?;
98 let status = Command::new(&*cmd)
99 .args(rest.iter().map(|s| s.as_ref()))
100 .stdin(Stdio::null())
101 .stdout(Stdio::null())
102 .stderr(Stdio::null())
103 .status()
104 .map_err(|e| EvalError::native_fn("exec-ok?", e.to_string(), sp))?;
105 Ok(Value::Bool(status.success()))
106 },
107 );
108
109 interp.register_fn(
110 "exec-capture",
111 Arity::AtLeast(1),
112 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
113 let (cmd, rest) = split_cmd(args, "exec-capture", sp)?;
114 let started = std::time::Instant::now();
115 let out = Command::new(&*cmd)
116 .args(rest.iter().map(|s| s.as_ref()))
117 .stdin(Stdio::null())
118 .output()
119 .map_err(|e| EvalError::native_fn("exec-capture", e.to_string(), sp))?;
120 let inv = Invocation::new(
121 &cmd,
122 rest.iter().map(|s| s.as_ref()).collect(),
123 started.elapsed().as_millis(),
124 );
125 Ok(capture_result(&inv, &out))
126 },
127 );
128
129 interp.register_fn(
130 "exec-with-stdin",
131 Arity::AtLeast(2),
132 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
133 use std::io::Write;
134 let payload = str_arg(&args[0], "exec-with-stdin", sp)?;
135 let (cmd, rest) = split_cmd(&args[1..], "exec-with-stdin", sp)?;
136 let started = std::time::Instant::now();
137 let mut child = Command::new(&*cmd)
138 .args(rest.iter().map(|s| s.as_ref()))
139 .stdin(Stdio::piped())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::piped())
142 .spawn()
143 .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
144 // `take()` so the pipe is dropped before we wait — a tool reading
145 // stdin to EOF deadlocks otherwise.
146 if let Some(mut sink) = child.stdin.take() {
147 // A child that exits or closes stdin without reading all of it
148 // (`true`, a tool that ignores stdin) makes this write fail with
149 // EPIPE. That is the child's choice, not our error: its exit
150 // status and output below are the answer. Treating it as an
151 // error made `neither_sink_puts_the_value_in_argv` fail whenever
152 // the child won the race (auto-release run 36634926576).
153 match sink.write_all(payload.as_bytes()) {
154 Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => {
155 return Err(EvalError::native_fn("exec-with-stdin", e.to_string(), sp));
156 }
157 _ => {}
158 }
159 }
160 let out = child
161 .wait_with_output()
162 .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
163 let inv = Invocation::new(
164 &cmd,
165 rest.iter().map(|s| s.as_ref()).collect(),
166 started.elapsed().as_millis(),
167 );
168 Ok(capture_result(&inv, &out))
169 },
170 );
171
172 interp.register_fn(
173 "exec-with-env",
174 Arity::AtLeast(2),
175 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
176 let pairs = env_pairs(&args[0], "exec-with-env", sp)?;
177 let (cmd, rest) = split_cmd(&args[1..], "exec-with-env", sp)?;
178 let mut c = Command::new(&*cmd);
179 c.args(rest.iter().map(|s| s.as_ref()))
180 .stdin(Stdio::null())
181 .stdout(Stdio::piped())
182 .stderr(Stdio::piped());
183 for (k, v) in &pairs {
184 c.env(k.as_ref(), v.as_ref());
185 }
186 let started = std::time::Instant::now();
187 let out = c
188 .output()
189 .map_err(|e| EvalError::native_fn("exec-with-env", e.to_string(), sp))?;
190 let inv = Invocation::new(
191 &cmd,
192 rest.iter().map(|s| s.as_ref()).collect(),
193 started.elapsed().as_millis(),
194 );
195 Ok(capture_result(&inv, &out))
196 },
197 );
198
199 interp.register_fn(
200 "sh-exec",
201 Arity::Exact(1),
202 |args: &[Value], _ctx: &mut ScriptCtx, sp| {
203 let script = str_arg(&args[0], "sh-exec", sp)?;
204 let started = std::time::Instant::now();
205 let out = Command::new("sh")
206 .arg("-c")
207 .arg(&*script)
208 .stdin(Stdio::null())
209 .output()
210 .map_err(|e| EvalError::native_fn("sh-exec", e.to_string(), sp))?;
211 // argv is recorded as the REAL three-element form `sh -c <script>`,
212 // not as the script text alone. That is the honest record: sh-exec
213 // is the one primitive that hands a string to a shell, and the
214 // record should show that a shell was involved.
215 let inv = Invocation::new("sh", vec!["-c", &script], started.elapsed().as_millis());
216 Ok(capture_result(&inv, &out))
217 },
218 );
219
220 install_capture_readers(interp);
221}
222
223/// `status-of` / `stdout-of` / `stderr-of` — the readers of the record
224/// [`capture_result`] produces, installed beside the producer.
225///
226/// ── WHY THESE ARE NATIVE ───────────────────────────────────────────────
227/// They were never builtins, yet this module's own header named them as
228/// consumers of the record, so a reader (human or agent) took them to exist.
229/// A script calling one died at runtime with `unbound symbol`.
230///
231/// `tatara-script lint` DOES catch that statically — its `unbound-symbol`
232/// rule reports `status-of is not bound by the interpreter or this program`
233/// (verified 2026-09-15 against the 0.3.55 binary). So the class was caught
234/// at lint time, in any repo that runs the lint. The scripts that shipped the
235/// call were ones where it did not run; the defect was gate coverage, not
236/// a missing check.
237///
238/// The fleet answered by hand-rolling them. Measured 2026-09-15 with `rg`
239/// across every `.tlisp` in pleme-io: **40 files in 8 repos** define them
240/// locally — nix 29, actions 3, substrate 3, and five `caixa-tlisp-*` — and
241/// `caixa-tlisp-core/stdlib.tlisp:148-152` is an extraction with ZERO
242/// consumers, because standalone `tatara-script file.tlisp` cannot load it.
243/// Every copy has the SAME body. That is convergent evidence, not duplication:
244/// independent authors kept arriving at one shape because the record forces it.
245///
246/// ── THE DEFAULTS ARE THE PRODUCER'S, NOT A CHOICE ───────────────────────
247/// `-1` for status is what [`capture_result`] itself writes when a child is
248/// killed by a signal (`out.status.code().unwrap_or(-1)`, and `(:status N)`
249/// is documented as "exit code, or -1 when killed by a signal"). Every local
250/// copy picked `-1` independently. Using anything else here would make a
251/// signal-killed child and a missing key report differently for no reason.
252///
253/// ── NON-BREAKING ────────────────────────────────────────────────────────
254/// A script-level `(define (status-of r) ...)` SHADOWS a native binding, so
255/// all 40 existing local definitions keep working unchanged. They can be
256/// deleted at leisure; nothing forces a migration.
257fn install_capture_readers(interp: &mut Interpreter<ScriptCtx>) {
258 use crate::stdlib::json::alist_lookup;
259
260 for (name, key, default) in [
261 ("status-of", "status", Value::Int(-1)),
262 ("stdout-of", "stdout", Value::Str(Arc::from(""))),
263 ("stderr-of", "stderr", Value::Str(Arc::from(""))),
264 ] {
265 interp.register_fn(
266 name,
267 Arity::Exact(1),
268 move |args: &[Value], _ctx: &mut ScriptCtx, _sp| {
269 Ok(alist_lookup(&args[0], key).unwrap_or_else(|| default.clone()))
270 },
271 );
272 }
273}
274
275fn split_cmd(
276 args: &[Value],
277 fname: &'static str,
278 sp: tatara_lisp::Span,
279) -> Result<(Arc<str>, Vec<Arc<str>>), EvalError> {
280 let mut it = args.iter();
281 let cmd = str_arg(
282 it.next().ok_or_else(|| {
283 EvalError::native_fn(fname, "expected at least 1 argument".to_string(), sp)
284 })?,
285 fname,
286 sp,
287 )?;
288 let rest = it
289 .map(|v| str_arg(v, fname, sp))
290 .collect::<Result<Vec<_>, _>>()?;
291 Ok((cmd, rest))
292}
293
294/// Read an alist of `(KEY VALUE)` string pairs.
295///
296/// Rejects a malformed entry rather than skipping it: a silently-dropped pair
297/// would run the child WITHOUT the credential and report success, which is a
298/// worse failure than an error.
299fn env_pairs(
300 v: &Value,
301 fname: &'static str,
302 sp: tatara_lisp::Span,
303) -> Result<Vec<(Arc<str>, Arc<str>)>, EvalError> {
304 let items = match v {
305 Value::List(items) => items,
306 _ => {
307 return Err(EvalError::native_fn(
308 fname,
309 "first argument must be an alist of (KEY VALUE) pairs".to_string(),
310 sp,
311 ));
312 }
313 };
314 let mut out = Vec::with_capacity(items.len());
315 for it in items.iter() {
316 match it {
317 Value::List(kv) if kv.len() == 2 => {
318 out.push((str_arg(&kv[0], fname, sp)?, str_arg(&kv[1], fname, sp)?));
319 }
320 _ => {
321 return Err(EvalError::native_fn(
322 fname,
323 "each env entry must be a 2-element (KEY VALUE) list".to_string(),
324 sp,
325 ));
326 }
327 }
328 }
329 Ok(out)
330}
331
332/// What was actually invoked, carried alongside the output so the record can
333/// answer WHICH binary ran and WHERE — not just what it printed.
334///
335/// ── ★ WHY THE RECORD NEEDED WIDENING ─────────────────────────────────────
336/// The record used to be `{status, stdout, stderr}`: three fields built from
337/// `std::process::Output` alone, so argv, the resolved binary and the cwd were
338/// not omitted by choice — [`capture_result`] never received them.
339///
340/// That gap is what makes a deshellify port's correctness argument uncheckable.
341/// The argument is always "the new thing does what the old thing did", and that
342/// is a COMPARISON; you cannot compare an invocation you did not record. Two
343/// concrete failure classes it left invisible:
344///
345/// * WHICH BINARY. `Command::new("kubectl")` runs whatever `PATH` resolved
346/// first, so a verdict gets attributed to a binary nobody declared — the
347/// silent-PATH-fallback class. With no resolved-path field, "did the right
348/// tool run" was unanswerable from the record and could only be settled by
349/// reading source. Measured in pleme-io/forge: 218 bare-literal spawns.
350/// * WHERE. A bump that targets a flake's read-only `/nix/store` source copy
351/// instead of the work tree fails with a bare `Permission denied (os error
352/// 13)`; the cwd is the field that says which one it was.
353///
354/// `duration-ms` separates "failed" from "hung", which a status alone cannot.
355struct Invocation<'a> {
356 program: &'a str,
357 args: Vec<&'a str>,
358 elapsed_ms: u128,
359}
360
361impl<'a> Invocation<'a> {
362 fn new(program: &'a str, args: Vec<&'a str>, elapsed_ms: u128) -> Self {
363 Self {
364 program,
365 args,
366 elapsed_ms,
367 }
368 }
369}
370
371/// Resolve `program` the way the OS just did, so the record names the binary
372/// that actually ran rather than the string we asked for.
373///
374/// A path-bearing program is already unambiguous and is returned as-is. A bare
375/// name is resolved through `PATH`; when resolution fails the field is the empty
376/// string — which is a FINDING (the spawn succeeded, so something ran, and we
377/// could not name it) and deliberately not the bare name again, because that
378/// would render "resolved" and "unresolved" as the same bytes.
379fn resolved_program(program: &str) -> String {
380 if program.contains(std::path::MAIN_SEPARATOR) {
381 return program.to_string();
382 }
383 which::which(program)
384 .map(|p| p.display().to_string())
385 .unwrap_or_default()
386}
387
388fn capture_result(inv: &Invocation<'_>, out: &std::process::Output) -> Value {
389 // argv as a LIST, never a joined string: re-quoting a command changes it,
390 // so a single string cannot be compared against what was run.
391 let mut argv = vec![Value::Str(Arc::from(inv.program))];
392 argv.extend(inv.args.iter().map(|a| Value::Str(Arc::from(*a))));
393
394 let cwd = std::env::current_dir()
395 .map(|p| p.display().to_string())
396 .unwrap_or_default();
397
398 Value::list(vec![
399 Value::list(vec![
400 Value::Keyword(Arc::from("status")),
401 Value::Int(out.status.code().unwrap_or(-1) as i64),
402 ]),
403 Value::list(vec![
404 Value::Keyword(Arc::from("stdout")),
405 Value::Str(Arc::from(String::from_utf8_lossy(&out.stdout).as_ref())),
406 ]),
407 Value::list(vec![
408 Value::Keyword(Arc::from("stderr")),
409 Value::Str(Arc::from(String::from_utf8_lossy(&out.stderr).as_ref())),
410 ]),
411 Value::list(vec![Value::Keyword(Arc::from("argv")), Value::list(argv)]),
412 Value::list(vec![
413 Value::Keyword(Arc::from("program")),
414 Value::Str(Arc::from(inv.program)),
415 ]),
416 Value::list(vec![
417 Value::Keyword(Arc::from("resolved")),
418 Value::Str(Arc::from(resolved_program(inv.program).as_str())),
419 ]),
420 Value::list(vec![
421 Value::Keyword(Arc::from("cwd")),
422 Value::Str(Arc::from(cwd.as_str())),
423 ]),
424 Value::list(vec![
425 Value::Keyword(Arc::from("duration-ms")),
426 Value::Int(inv.elapsed_ms.min(i64::MAX as u128) as i64),
427 ]),
428 ])
429}