SCUA

News

SCUA can now exec

August 31, 2026

An agent that can read files, call an API and write a report still cannot run the build it just fixed. As of 0.20.0 it can. exec starts other programs, hands you their output and their exit status, and gives you a handle you can watch and cancel.

import exec
import bytes
import sys

match exec.run_any(["git", "log", "--oneline", "-n", "5"])
  Ok(r)    -> match bytes.to_string(r.stdout)
                Ok(text) -> sys.write(text)
                Error(_) -> nil
              end
  Error(e) -> print(`could not run git: {e.message}`)
end

Ok means the command ran, and status is what it said. grep exiting 1 because it found nothing is an Ok, because it worked. Error means it never started or could not finish, and kind is one of not_allowed, bad_argument, spawn_failed, timeout or unsupported, so a retry policy can tell "try again" from "never going to work" without parsing an error message.

#You can declare the commands in advance

run_any runs anything the script decides to run, which is what an agent wants and what a CI runner does not. There is a second form for when you already know what a program needs. Declare the commands in the capability file, and it can run those and nothing else:

exec = [
  ["zig", "build", "test"],
  ["git", "log", "--oneline", "-n", "{int}"],
  ["grep", "-c", "{string}", "--", "{string}"],
]
exec.run(["zig", "build", "test"])

The call site is the same line either way. Hardening a program that already works means pasting the argvs you already wrote into a file and dropping the _any from the calls, which is the whole design goal: if tightening security meant rewriting every call site, nobody would bother.

A declaration pins the program and its flags. Holes are for the values you pass. Your argv has to match a declaration exactly, same length and literals identical, and a hole fills exactly one argument that is never re-parsed, so a b; echo c arrives as one argument containing a space and a semicolon. A hole sitting before a literal -- refuses anything starting with a dash, so a search term cannot turn itself into a flag and change what the command does. Anything that does not match comes back as Error({ kind = "not_declared" }) with the closest declaration named, which is usually one wrong flag away.

Errors in the file itself are caught at startup rather than surfacing later as a confusing failure: a hole where the program name belongs, an invented hole like {count}, an empty template. So is exec = true. There is no "all commands" setting here; that is exec_any, and you have to ask for it by name.

#There is no shell

argv is an array and it goes to the operating system unparsed.

let branch = user_input                       -- "; cat /etc/passwd" if you like
exec.run_any(["git", "checkout", branch])     -- still just a branch name

Nothing re-reads that value, so there is no quoting to get right and no injection to filter. When you want a shell, for pipes or globs, you spawn one yourself:

exec.run_any(["sh", "-c", "cargo build 2>&1 | tail -20"])

#Watching and stopping a command

run_any waits. spawn_any gives you a handle instead, and then exec.read and exec.read_err stream the two channels separately, so a warning never lands in output you were about to parse. exec.done asks whether it has finished without blocking, so a loop can do other work while a build runs.

exec.kill signals the whole process group. This matters in practice: kill only the process you spawned and an sh -c 'server &' leaves the server running, holding the pipe you are reading, until your deadline expires waiting for output from a program you thought you had killed.

Every call carries a deadline, and it cannot be switched off. A command burns wall clock rather than interpreter steps, so --max-ops cannot see it and only a clock can bound it. Captured output is capped too, and when the cap fires you keep both ends with a marker between them. A build that fails after a megabyte of warnings puts the reason on the last line, and a cap that kept only the first 256 KiB would hand you the warnings and throw away the error.

Under --io=async, exec yields the way http and fs already do, so a command issued from an actor or an http.serve handler runs alongside everything else. Four actors each running a one-second command take about a second between them.

Windows gets the same surface and the same guarantees. Cancellation there goes through a job object a child cannot escape, and which the OS tears down even if SCUA dies first. A killed command still reports 137. One caveat: Windows takes a command string rather than an argv array, so your arguments are quoted for the child using the standard rules, which is right for almost every program and wrong for the few that parse their own command line.

#Why --allow-all skips it

Every other capability in SCUA narrows what a script can reach. --allow-fs=./data is one directory. --allow-net=api.example.com is one host. You can say "this program holds only these things" and have it be true.

exec is the exception. A command you run inherits your operating-system user, not your SCUA grants, so a script holding exec and no fs can still run cat, and one holding exec and no net can still run curl. Granting it hands over your account. Every protection above still applies, and none of them turns it back into a sandbox.

That is why --allow-all does not grant it, and it is the only capability --allow-all skips. One convenient flag should not hand over your whole account. You type --allow-exec-any on purpose, or you declare the commands and get the narrow form instead.

Run commands is the full how-to, and examples/exec.scua is a runnable example.