process-exec: cwd: never worked, and every call leaked a process
t-b07e5f. with-current-directory is a PROCEDURE taking (path thunk), not a macro taking a body, and this call site passed the spawn's RESULT where the thunk belonged:
(with-current-directory cwd
(apply process-spawn ...)) ; spawn is an ARGUMENTSo the argument was evaluated first. Measured before the fix, with a control proving the probe was live:
without cwd (control) => "/home/daviwil/.../task-system-cwd-thunk"
with cwd: => RAISED "with-current-directory: expected
thunk to satisfy procedure?,
got #<object>"STATE PLAINLY WHAT THIS MEANS FOR ANYONE READING 0.1.3: process-exec with cwd: has never worked. It was not subtly wrong, it raised. Anyone who wrote code depending on it has been getting an exception and has probably worked around it -- slate's env --chdir shim exists for exactly this reason.
AND IT WAS WORSE THAN INERT. The spawn ran BEFORE the raise, so the child was already started when the call threw and nothing reaped it. Measured: an orphaned sleep 417 with ppid 1, and its cwd was the CALLER's directory rather than the requested one. Every process-exec call with cwd: leaked a process into the wrong directory. After the fix: exit 0, zero orphans.
Two regression tests, and both assert the DIRECTORY rather than the absence of a raise -- a version that silently ignored cwd: would also stop raising, which is the original defect wearing a quieter coat. The second test pins that the process-global mutation does not leak to the caller, so the fix cannot become a new bug standing where the old one was.
WHY A ONE-LINE FIX HERE MATTERS UPSTREAM: slate wraps agent exec in env --chdir to work around this. That wrapper is caller-visible argv, which is what makes env requestable through an exec allowlist -- and env runs anything, so the allowlist is void (model 7.6, t-289c60). The model's preferred fix is to stop expressing infrastructure as caller argv at all, and removing this bug is what makes that available.
Version 0.1.2 -> 0.1.3 in the same commit so it merges atomically.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DYn8wxSdjveEgK9NRxFcNA
package.sgl | 2 +-
scripts/probe-exec-cwd.sgl | 42 ++++++++++++++++++++++++++++++++++++++++++
src/sigil/system/process.sgl | 15 ++++++++++++++-
test/test-system.sgl | 29 +++++++++++++++++++++++++++++
4 files changed, 86 insertions(+), 2 deletions(-)package.sglmodified
(package name: "sigil-system" version: "0.1.2" version: "0.2.0" sigil: "0.20" description: "System capability library: filesystem, processes, and PTY sessions behind a grant-checked surface" url: "https://codeberg.org/sigil/sigil-system"scripts/probe-exec-cwd.sgladded
;;; Probe for process-exec's `cwd:` argument (t-b07e5f).;;;;;; Prints one line per observable fact. Run under `sigil test <file>`.;;; The no-cwd arm is the POSITIVE CONTROL: it proves the probe can see a;;; working directory at all, so a wrong answer from the cwd arm cannot;;; be blamed on the probe.(import (sigil io) (sigil string) (sigil process) (sigil system grant) (sigil system process))(define ROOT (getenv "EXEC_CWD_PROBE_ROOT"))(define INNER (string-append ROOT "/inner"))(define g (make-grants principal: "exec-cwd-probe"))(grant-add! g (string-append "fs:rw:" ROOT))(grant-add! g "exec:full")(define (trimmed s) (let loop ((i (string-length s))) (cond ((= i 0) "") ((char=? (string-ref s (- i 1)) #\newline) (loop (- i 1))) (else (substring s 0 i)))))(define (run label thunk) (println "~a ~s" label (guard (e (#t (list 'RAISED (error-object-message e)))) (trimmed (dict-ref (thunk) stdout: "")))));; POSITIVE CONTROL FIRST: without cwd:, pwd must report the process's;; own directory. If this is not a real path the probe is broken and;; nothing below it means anything.(run "CONTROL-no-cwd" (lambda () (process-exec g (list "pwd"))));; THE ARM UNDER TEST. "stopped raising" is NOT the bar: a function that;; silently ignores cwd: also stops raising, and that is the original;; defect wearing a quieter coat. The bar is that pwd reports INNER.(run "ARM-with-cwd" (lambda () (process-exec g (list "pwd") cwd: INNER)))(println "EXPECTED-cwd ~s" INNER);; The caller's own directory must be UNCHANGED afterwards: the fix;; scopes a process-global mutation, so a leak of that mutation would be;; a new bug in place of the old one.(run "AFTER-no-cwd" (lambda () (process-exec g (list "pwd"))))src/sigil/system/process.sglmodified
;; working directory is process-global, so scope the ;; change tightly around the spawn (no yield point in ;; between). ;; ;; THE LAMBDA IS THE WHOLE FIX AND IT IS NOT COSMETIC. ;; `with-current-directory` is a PROCEDURE taking ;; (path thunk), not a macro taking a body. Without the ;; lambda the spawn was an ARGUMENT: it ran FIRST, in ;; the caller's directory, and its result was passed ;; where a thunk belonged — so the call raised ;; "expected thunk to satisfy procedure?" AFTER having ;; already started a child that nothing then reaped. ;; Measured before the fix: an orphaned process with ;; ppid 1, running in the CALLER's cwd rather than in ;; `cwd`. So `cwd:` did not merely fail to take effect; ;; every call leaked a process into the wrong directory. (with-current-directory cwd (apply process-spawn (car argv) (cdr argv))) (lambda () (apply process-spawn (car argv) (cdr argv)))) (apply process-spawn (car argv) (cdr argv))))) (unless p (error "process-exec: failed to spawn" argv))test/test-system.sglmodified
(let ((g (make-grants))) (assert-error (process-exec g '("echo" "no"))))) (test "cwd: runs the child in that directory" ;; REGRESSION, t-b07e5f. `with-current-directory` is a PROCEDURE ;; taking (path thunk), and this call site passed the spawn's RESULT ;; where the thunk belonged — so `cwd:` raised ;; "expected thunk to satisfy procedure?" AFTER already starting a ;; child, in the caller's directory, that nothing reaped. ;; ;; THE ASSERTION IS ON THE DIRECTORY, NOT ON THE ABSENCE OF A RAISE. ;; A version that silently ignored `cwd:` would also stop raising, ;; and that is the original defect wearing a quieter coat. (let ((g (make-grants)) (dir (make-temp-directory))) (grant-add! g "exec:allowlist:pwd") (grant-add! g (string-append "fs:rw:" dir)) (let ((r (process-exec g '("pwd") cwd: dir))) (assert-equal (dict-ref r exit:) 0) (assert-true (string-contains? (dict-ref r stdout:) dir))))) (test "cwd: does not leak the process-global directory to the caller" ;; The fix scopes a process-global mutation. A leak of that mutation ;; would be a new bug standing where the old one was. (let ((g (make-grants)) (dir (make-temp-directory)) (before (current-directory))) (grant-add! g "exec:allowlist:pwd") (grant-add! g (string-append "fs:rw:" dir)) (process-exec g '("pwd") cwd: dir) (assert-equal (current-directory) before))) (test "non-zero exit surfaces" (let ((g (make-grants))) (grant-add! g "exec:allowlist:false")