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.

#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 loudr.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_anyany 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 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