SCUA

How-to

Run commands

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.

#Send input to a command

Pass a string or bytes as opts.stdin. SCUA sends the input while draining both output streams, then closes stdin and waits for the command to finish. Strings are sent as UTF-8; bytes are sent unchanged. No newline is added.

Save this as exec_stdin.scua on macOS or Linux, where sort is available:

import exec
import bytes
import sys

match exec.run_any(["sort"], { stdin = "pear\napple\n", timeout_ms = 5000 })
  Ok(result) -> do
    match bytes.to_string(result.stdout)
      Ok(text) -> sys.write(text)
      Error(why) -> print(why)
    end
    print(`exit {result.status}`)
  end
  Error(why) -> print(why.message)
end

Run it:

$ scua --allow-exec-any exec_stdin.scua
apple
pear
exit 0

Omitting stdin, passing nil, or passing an empty string or bytes sends EOF without any data. Other input types return Error with kind = "bad_argument" before the command starts. A child may close stdin without consuming all the input; its exit status still determines success, so check result.status.

The timeout_ms deadline covers sending input, draining output and waiting after the process has spawned. Hitting the capture limit does not stop pipe draining; check result.truncated before parsing captured output. exec.run has the same input behavior, with the declared-command checks described below. This also works under --io=async.

#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. List them under [exec] in the project's scua.toml, beside the program:

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

call exec.run (no _any):

exec.run(["zig", "build", "test"])
exec.run(["git", "log", "--oneline", "-n", str(count)])

and launch with --allow-exec, which grants exactly that list:

scua --allow-exec build.scua

The list travels with the program, so whoever runs it can read in one place every command it may run, and a packaged app carries it too. --allow-exec with no [exec] commands list, or an empty one, is refused at start-up rather than granting nothing. The list is always the project's own: for a script inside a locked package's folder (scua deps/scua/tool/tool_dev.scua), it is the scua.toml of the project whose lock vendored the package, and a scua.toml inside the package's folder that lists commands is refused, so a dependency can never grant commands to the program that uses it.

The program must not be able to edit its own list either, or one run could add a command for the next to execute. A launch where --allow-fs reaches the scua.toml (--allow-fs=. from the project folder, say) stops with E-EXEC-POLICY before anything runs. Give the program a folder of its own for what it writes:

scua --allow-exec --allow-fs=./data build.scua

A capability file --allow-file reads can declare the same list, as exec = [ … ], for a launcher that keeps its grants outside the project:

scua --allow-file=policy.toml build.scua

The same rule holds for that file: its exec list is refused when the file sits in a folder the program may write.

--allow-exec-any covers exec.run too: a program that may run any command may run a listed one. If a list is declared as well, exec.run still checks its argv against it.

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 of a b; echo c is 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