Running Commands
Run a local program from a flow, stream its output, and hand a model a safe toolbox.
The cli-run block runs a command-line
tool, watches its output a line at a time, and carries on with how it ended.
Three samples build this up:
cli-run.yaml,
cli-sse.yaml,
and cli-agent.yaml.
The simplest run
- type: cli-run
name: build
program: '"go"'
args: '["build", "./..."]'
workDir: /src
env: [PATH, HOME]
timeout: 5mThe chain carries on with {exitCode, stdout, stderr, lines}. A non-zero exit
errors the block by default so the error path sees
it; onExit: continue leaves the decision to a later block.
program is a CEL expression, so a constant is written '"go"': quoted once for
YAML and once for CEL. A bare name is resolved through $PATH; an absolute path
works too. No allow list is needed when the program is a literal.
Watching the output
An events path runs once per output line, and once when the command exits:
emit: [stdout, stderr, exit]
events:
process:
- type: log
name: line
settings:
message: body.textEach event's body is {type, text, index}, where type is stdout or
stderr. The final one is {type: "exit", exitCode, lines}; it fires for every
run, even one that printed nothing, and goes through emit like any other.
samples/cli-sse.yaml leaves it out because the route's own finalEvent already
carries the exit status.
The path is an observer and its result is discarded, so a broken sink is a log line rather than a failed message.
Streaming to an HTTP caller
Point that path at an sse-event behind an SSE
route and the caller sees output live:
source:
connector: api
type: http
settings:
path: /build
sse:
enabled: true
maxDuration: 5m
finalEvent: true
finalEventName: done
process:
- type: cli-run
name: build
program: '"go"'
args: '["build", "./..."]'
emit: [stdout, stderr]
events:
process:
- type: sse-event
name: line
settings:
event: line
data: '{"stream": body.type, "text": body.text}'
ifClosed: stopNothing names the stream: the route stamps its address into vars.sseStream,
and an event message carries the parent's variables. cli-run blocks until
the command exits, so the request stays open for the whole run.
The events path runs between reads of the process's pipes, so a slow client
blocks the child on write instead of letting its output pile up in memory.
With ifClosed: stop, a caller that hangs up stops the command.
Set the command's own timeout below the route's maxDuration, so the command
gives up first and the caller learns why.
Giving a model a toolbox
Because program is an expression, an ai-agent tool
branch can offer a model a set of commands and let it choose. allow is checked
on every message, before anything is spawned.
- type: cli-run
name: run
program: vars.program
args: vars.args
allow: [ls, df, date]
env: [PATH]
workDir: /tmp
onExit: continueHave the flow map a command name to a program, rather than letting the model supply one:
- type: multi-transform
name: resolve
settings:
transforms:
- setVar: program
value: >
body.command == "list-files" ? "ls" :
body.command == "disk-usage" ? "df" : ""A name outside the list maps to "", so nothing is spawned; anything else the
mapping could produce still has to be on allow. In its own sourceless flow, the
mapping is testable with no model and no API key, as
samples/cli-agent_test.yaml does.
What keeps this safe
The allow list is the boundary when you declare one. allow is optional,
and a block without one may run anything; the builder warns at startup when such
a block takes its program from the message.
Entries may be bare names or absolute paths, compared after resolution: a
bare name matches the absolute path $PATH resolves it to, and /bin/../bin/sh
cannot dodge an entry for /bin/sh. Symlinks are not followed, because on a
busybox system following them would make ["cat"] also admit sh. Every entry
is resolved at build time, so one naming an uninstalled program fails at startup.
No shell is involved: arguments are passed as argv, so a semicolon, a pipe or a
$(…) in a model-supplied argument is just bytes to the callee. The child sees
exactly the variables named in env and no others, so it cannot read an API key
the integration holds. Interpreters are refused on an explicit list unless
allowInterpreters: true, because listing sh or python allows everything
they can reach.
Declare an allow list for anything you deploy. A flow the internet can reach with no allow list is remote code execution, whatever else the flow does.
The interpreter check is a guardrail, not a sandbox. The list can never be
complete (git runs a pager, tar has --to-command, find has -exec), so
write the allow list as narrowly as the job allows and treat everything on it
as something the caller can run.