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.