exec runs other programs. It is how a script shells out
to git, drives a build, or — the case it was designed
around — lets an agent run whatever command it just decided to run.
import exec
import bytes
match exec.run_any(["git", "log", "--oneline", "-n", "5"])
Ok(r) -> print(bytes.to_string(r.stdout))
Error(e) -> print(`could not run git: {e.message}`)
end
scua --allow-exec-any log.scua
#Read this part before you turn it on
Every other capability narrows what a script can do.
--allow-fs=./data is one directory.
--allow-net=api.example.com is one host. You can say "this
program holds only these things" and it is true.
exec is different, and the difference is not a
detail. A command you run inherits your operating-system
user, not your SCUA grants. A script holding exec and
no fs can still run cat. A script holding
exec and no net can still run
curl. The sentence "this partition holds only X" stops
being true the moment exec is in X.
So granting exec is delegation, not
containment. Every protection below still applies and none of
them makes it a sandbox.
That is also why --allow-all does not grant
it. It is the one exception, and a deliberate one: one
convenient flag should not hand over your whole account. You type
--allow-exec-any on purpose, or you don't get it.
#There is no shell
argv is always 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. Shell injection isn't mitigated here; it isn't reachable.
If you want a shell — pipes, globs, &&
— ask for one:
exec.run_any(["sh", "-c", "cargo build 2>&1 | tail -20"])
That is fine, and it is the honest spelling: the shell string is visibly yours.
#The environment is not inherited
A command gets only what you name:
exec.run_any(["env"], { env = { LANG = "C" } }) -- the child sees LANG, and nothing else
Inheriting would quietly hand every API key in your environment to
every command you run — and SCUA gates the environment behind
env for exactly that reason.
The trap, and it will cost you an afternoon if you meet it
cold. The program name is looked up using your
PATH, so this works with no environment at all:
exec.run_any(["cargo", "build"]) -- finds cargo. Fine.
But a shell resolves its own commands against the
child's PATH, which is empty:
exec.run_any(["sh", "-c", "cargo build"]) -- "cargo: not found", for a cargo that is installed
The easy case works and the interesting one doesn't. To give a child
a usable environment, hold env and pass it through:
import env
exec.run_any(["sh", "-c", "cargo build"], {
env = { PATH = env.get("PATH") or "", HOME = env.get("HOME") or "" }
})
scua --allow-exec-any --allow-env=PATH,HOME build.scua
To hand a child an environment, you must hold one. That is the capability model working, not a workaround for it.
#A command that ran and failed is not an error
match exec.run_any(["grep", "TODO", "notes.txt"])
Ok(r) -> print(`grep exited {r.status}`) -- 1 means "no matches". It ran.
Error(e) -> print(`grep never started: {e.kind}`)
end
Ok means the command ran; status is what it
said. Error means it never ran, or could not finish —
kind is one of not_allowed,
bad_argument, spawn_failed,
timeout, unsupported, so a retry policy can
tell "try again" from "never going to work" without reading prose.
#Watching a command as it runs
run_any waits for the command to finish. When you want
to watch it — a build scrolling past, a server you leave
running — spawn it and hold the handle:
match exec.spawn_any(["cargo", "build"])
Ok(p) -> do
while true do
match exec.read(p, 4096) -- empty bytes mean the output ended
Ok(chunk) -> do
if len(chunk) == 0 then break end
match bytes.to_string(chunk) -- a chunk can split mid-character
Ok(text) -> sys.write(text)
Error(_) -> nil -- buffer with bytes.concat if you need whole text
end
end
Error(_) -> do break end
end
end
print(`exit {match exec.wait(p, 60000) Ok(s) -> s Error(_) -> -1 end}`)
exec.close(p)
end
Error(e) -> print(e.message)
end
exec.read and exec.read_err keep the two
streams apart, so a warning never corrupts output you were going to
parse. exec.done asks whether it has finished without
waiting, so a loop can do other work while a build runs.
#Cancelling
exec.kill(p) -- ask it to stop (SIGTERM)
exec.kill(p, "kill") -- make it stop (SIGKILL)
The signal goes to the whole process group, so a
command that started its own children takes them with it. This matters
more than it sounds: kill only the process you spawned and a
sh -c 'server &' leaves the server running, holding the
pipe you are reading, and your deadline expires waiting for output from
a program you thought you had killed.
exec.close does the same teardown and releases the
handle. You do not have to call it — every command is torn down when the
program ends — but a long-running script should, or it accumulates
finished processes.
#Deadlines and output size
Every call has a deadline and it cannot be switched
off. A command burns wall-clock, not interpreter steps, so
--max-ops does not bound it — only a clock does.
exec.run_any(["./flaky-test"], { timeout_ms = 30000 })
Captured output is capped (256 KiB by default) and truncation is
loud — r.truncated tells you. When it
fires you keep both ends, with a marker between
them:
exec.run_any(["make"], { max_bytes = 1 << 20 })
Keeping both ends is deliberate. A build that fails after a megabyte
of warnings puts the reason on the last line, and a cap that
keeps the first 256 KiB hands you the warnings and throws away the
error. Pass keep = "head" or keep = "tail" if
you want one end whole.
#Declaring commands ahead of time
Everything above is run_any — any command,
chosen at runtime. That is what an agent needs, and it is delegation:
you are handing over your account.
When you already know which commands a program may run — a CI runner,
a deploy script, a game's mod system — you can say so, and then it can
only run those. Declare them in the same capability file
--allow-file already reads:
exec = [
["zig", "build", "test"],
["git", "log", "--oneline", "-n", "{int}"],
["grep", "-c", "{string}", "--", "{string}"],
]
and call exec.run (no _any):
exec.run(["zig", "build", "test"])
exec.run(["git", "log", "--oneline", "-n", str(count)])
scua --allow-file=policy.toml build.scua
The call site is the same line either way. That is
deliberate: the way you harden a working program is to paste the argvs
you already wrote into the file and drop the _any — not to
rewrite every call. If tightening security meant a rewrite, nobody would
do it.
A declared command fixes the program and its flags. Holes are operands only.
- Your argv must match a declaration exactly: same length, literals identical, holes filled.
- A hole (
{string},{int}) becomes exactly one argument and is never re-parsed. A value ofa b; echo cis one argument containing spaces and a semicolon, not three. - A hole before a literal
--refuses anything starting with-, so a search term cannot become a flag and change what the command does. After a--, a leading dash is just a filename. - No match is
Error({ kind = "not_declared" }), and the message names the closest declaration — the usual cause is one wrong flag.
Mistakes in the file are caught at startup, not at 3am: a
hole where the program should be, a name like {count} that
isn't a real hole, or an empty template all refuse the run with a
message saying which. And exec = true is refused rather
than read as "any command" — that is exec_any, and you have
to name it.
What this does and does not promise. A script cannot
escape your declarations. It does not stop you declaring an
escape: ["sh", "-c", "{string}"] satisfies every rule above
and grants everything. That is a real delegation, and the point of the
file is that it is one visible line a person can read.
#Where a command runs
opts.cwd sets the working directory per call, so a
script can walk a tree:
exec.run_any(["make", "test"], { cwd = "packages/core" })
With --allow-fs, it defaults to your granted root.
Without it, the command starts where your program did. Note this is
convenience, not containment — a command can change its own
directory the moment it starts, like anything else it does as your
user.
#Not yet
Interactive programs and PTYs; pipelines between two children (spawn a shell and let it do that); and letting a running command's output be a stream you pass around.
On Windows the uninherited environment bites harder than on
POSIX, and it is worth knowing before it costs you an
afternoon. sh supplies a fallback PATH of its
own, so ["sh","-c","echo hi"] works with an empty
environment. cmd supplies nothing: cmd /c echo
works because echo is a builtin, while
cmd /c sort fails with "'sort' is not recognized as an
internal or external command" — because sort.exe needs
a PATH the child does not have. A Windows child that must
find anything wants at least SystemRoot and
PATH passed in opts.env.
Windows works, with one honest caveat. Windows takes
a command string rather than an argv array, so your argv is
quoted for the child using the standard rules — right for the
overwhelming majority of programs, and wrong for the handful that parse
their own command line (cmd.exe most notoriously). If you
need certainty there, pass one pre-quoted argument, as you would in any
language on that platform. Cancelling still takes the whole tree with
it: Windows uses a job object, which is stronger than a process group —
a child cannot escape it, and the OS tears it down even if SCUA dies
first.
Running a command yields under
--io=async, the way http and
fs do — so a command issued from an actor handler or an
http.serve handler runs alongside everything else. Without
that flag it is a plain blocking call, which is right for a script and
wrong for a server.
#See also
- Read environment
variables — the
envcapability this needs - Run untrusted code — a sandbox
never holds
exec, in any form examples/exec.scua— every feature above, runnable