Workflow scripts: add a sanctioned sh() primitive (allowlisted) for deterministic shell hops
Summary
Workflow scripts (the Workflow tool) expose agent(), parallel(), pipeline(), log(), phase(), budget, and workflow() — but no way to run a shell command directly. The only escape hatch is to spawn an agent() whose entire job is to run one deterministic command. In practice, most hops in an orchestration script are exactly that: a full model context booted to run git status, open a PR, or invoke a project helper script. This is pure token and latency waste. A first-class sh() primitive would remove it.
Problem, concretely
In a layer-by-layer implementation workflow, the per-item chain spawns ~11 agents on the happy path. About 7 of them do zero reasoning — they exist only because the script can't run a shell command itself:
| Hop | What the agent actually does |
|---|---|
| create-branch | invoke a project helper to cut a worktree |
| verify | git status --porcelain |
| gate | run the project's pre-commit check |
| commit | git commit && git push |
| open-pr | invoke a project helper to open a PR |
| ci-wait | invoke a project helper that blocks on CI |
| merge-pr | invoke a project helper to merge |
Each is a cold model-context load (latency) plus input/output tokens (cost), to produce output a case statement could parse. Across a multi-item, multi-stage run this dominates both the token bill and wall-clock.
Today the only mitigation is batching several commands into one agent context (still a model in the loop) — better, but still paying a model to run a pipe.
Proposed API
// Run a shell command from within a workflow script.
// Resolves with { stdout, stderr, code } (or rejects on non-zero if { check: true }).
const { stdout, code } = await sh(["git", "-C", wt, "status", "--porcelain"], {
cwd: someDir, // optional
timeout: 120_000, // optional, ms
check: false, // optional; true → reject on non-zero exit
})
Semantics to mirror the existing primitives:
- Counts toward
budgetthe same way agents do (or is explicitly free — either is fine, just be explicit). - Cancellable via the same abort signal as
agent()(kill/pause). - Deterministic for resume: on
resumeFromRunId, an unchangedsh()call returns its cached result, exactly likeagent(). This is the important one — without it,sh()breaks the resume model. - Concurrency: participates in the same in-flight cap as
agent()when used insideparallel()/pipeline().
Security: the allowlist requirement
This is the part that needs care, so I'm calling it out explicitly.
A raw sh() is a shell-injection surface. Workflow scripts already interpolate model-produced strings (repo names, item numbers, branch slugs) into command strings. Today the script author hand-validates every one of these before it reaches a command — e.g. regex-guarding names/numbers against ^[A-Za-z0-9_./-]+$ before interpolation. If sh() ships without support for this, that discipline silently moves from "enforced in one reviewed place" to "hoped for at every call site."
Two things would make sh() safe by construction:
- An allowlist of executables.
sh()should only be able to invoke commands whoseargv[0]resolves to an approved set — e.g. a configured helper directory, plusgit, plus whatever the host opts in. A call to anything else fails closed. (Analogous to how the interactive Bash tool is permission-gated for interactive sessions — workflows currently have no equivalent.) - Argument-array form, no shell interpolation. Prefer
sh(["git", "-C", wt, "status"])(execFile-style, no shell) over a single string that goes through/bin/sh -c. The array form removes the injection class entirely — interpolated values become inert argv entries, never parsed as shell.
Support both string (convenience, /bin/sh -c, allowlisted) and array (safe, no shell) forms; document the array form as the default for anything touching interpolated values.
Why not just keep using agent() for shell
- Cost: a model context to run
git statusis orders of magnitude more expensive than the syscall. - Latency: each spawn is a cold-context load; a 10-hop chain pays it 10×.
- Reliability: today a deterministic command can "fail" because the wrapping agent dropped its structured output — teams end up writing a whole retry/degrade helper to paper over that.
sh()removes the failure mode: a command either exits 0 or it doesn't.
Impact if shipped
In the workflow that motivated this, sh() would collapse the ~7 shell hops per item to zero-token calls, taking the per-item chain from ~11 model spawns to ~4 (the ones that actually reason: implement, gate judgement, review, fix). That's the single largest available reduction in both token spend and wall-clock for this class of workflow.
Non-goals
- Not asking for general filesystem / Node API access — just command execution, allowlisted.
- Not asking to weaken any interactive Bash permissioning; workflows run headless and should have their own allowlist, not inherit the interactive prompt flow.