- Forthcoming
- Structured Commands
Structured Commands
A recipe today is shell text, and a rendered value that contains a semicolon, a quote, or a space can quietly become
syntax. The proposed invoke block fixes the command's structure before any template value is evaluated,
so an interpolation fills one argument and never changes how many there are. Environments, working directories,
scratch space, and pipelines become fields rather than shell idioms; shells remain available, by name, when a
recipe genuinely needs one.
Status on 19 October 2026
- Not in v0.1.0-beta4. Recipes are shell strings in v0.1.x. No structured-command code is on Netsuke's main branch, and the README lists structured arguments and recipe environment mappings as not implemented yet.
- Designed, not ratified. RFC 0001 and its three amendments — 0009 (working directories), 0010 (runtime bindings and temporary directories), and 0011 (named shells) — are all Proposed. ADR-019, which selects the shell allow-list, was accepted on 2 September 2026.
- Known conflicts. The RFCs disagree in places — on the base for relative stream paths, and on two overlapping temporary-directory spellings. Roadmap task 12.1.1 must reconcile them before implementation starts, and issue #803 tracks the specifics, so details on this page may change.
- One task ticked of 42. Roadmap phases 11 to 15 carry the work; only 11.1.1, the paper contract for named shells, is done. Draft PR #778 plans the shell-selection vocabulary (11.2.1) and changes no production code.
- Planned for v0.2.0. The user's guide and the v0.1.0 migration guide name v0.2.0, and say structured commands will not be backported by quietly changing legacy recipes.
Why structure a command
A command is one shell string or a list of them. Environment setup, argument quoting, and process invocation
all travel in the same text, which is hard to inspect, less portable than the manifest schema, and easy to make unsafe when
a rendered value carries whitespace or shell metacharacters. Jinja values are not shell-quoted for you today; the
security page documents the shipped contract. RFC 0001's
motivating example is an ordinary coverage recipe:
actions:
- name: coverage
command:
- 'echo "coverage linker flags: -fuse-ld=lld"'
- >-
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=clang
RUSTFLAGS="-D warnings -C link-arg=-fuse-ld=lld"
CFLAGS="-fuse-ld=lld"
LDFLAGS="-fuse-ld=lld"
cargo llvm-cov --lcov --output-path lcov.info
--all-targets --all-features
Not render, then tokenize
Splitting the text after rendering is too late: an interpolated semicolon, redirection, quote, or run of whitespace has already become syntax or moved an argument boundary. The rule is to parse structure first, evaluate interpolation second, and never parse an interpolated value again.
Not a YAML argv list
An explicit list is safe and hostile to write: a six-word command becomes a tall stack of list items. The RFC rejects it in favour of an ergonomic string that Netsuke parses under its own small grammar, not POSIX shell or PowerShell rules.
One contract on every host
On Windows every legacy recipe runs under Windows PowerShell (powershell.exe), not pwsh, whatever launched Netsuke. Direct invocation needs no interpreter at all, so the same invoke line means the same argv on Linux, macOS, and Windows.
Argv-safe commands with invoke
command gains two new forms beside the existing ones: a mapping with an invoke key, and a list that
mixes strings, invoke blocks, rule: items, and script: items. With shell
absent or false, invoke is an argv template. Quotes group words and are removed; a backslash outside
quotes is literal, so C:\tools\bin survives; unquoted |, &, ;,
<, and > are errors that catch accidental pipelines and redirections; and nothing expands
— no $HOME, %TEMP%, globs, or $(…).
# Existing scalar shell command.
command: cargo test
# New structured command block.
command:
invoke: cargo test
env:
RUST_BACKTRACE: "1"
# New heterogeneous command list.
command:
- invoke: cargo build
- rule: package
- script: |
printf '%s\n' 'package complete'
command:
invoke: frobnicate --output={{ destination }}
# destination = "release files; delete nothing"
# argv: ["frobnicate", "--output=release files; delete nothing"]
vars:
coverage_args: [--all-targets, --all-features]
command:
invoke: cargo llvm-cov {{ coverage_args }}
# argv: ["cargo", "llvm-cov", "--all-targets", "--all-features"]
command:
invoke: clang {{ ins }} -o {{ outs }}
# one argv element per path, spaces and all
A scalar fills exactly one word. A list splices its elements only when it stands as a whole unquoted word. A stray
$in or $out earns a targeted diagnostic pointing at {{ ins }} and
{{ outs }}. On Windows, direct mode refuses a resolved cmd.exe, .bat, or
.cmd target, because cmd.exe can reread separate arguments as shell source. Ninja still schedules the
build: a structured edge calls a hidden Netsuke action runner by opaque ID, and the generated Ninja line carries no rendered
user arguments or environment values.
Argv-safe is not a sandbox. Direct mode keeps each value inside its argument. It cannot stop a value
beginning with - being read as an option, a program treating an argument as code, or the executable itself
misbehaving. In the RFC's words, Netsuke preserves the argv boundary; it cannot validate every callee's application grammar.
Program-specific guards such as -- remain the author's job.
Roadmap 12.1 Command structure before values · 12.2 Isolated execution · 12.3 Build and generate workflows
Environments and working directories
A block's env is an exact overlay for that one child process. It needs no shell quoting and does not persist to
later blocks. Duplicate keys are invalid, compared case-insensitively on Windows. RFC 0009 adds cwd, so a
structured command no longer has to fall back to shell: true and cd dir && ….
actions:
- name: coverage
command:
- invoke: 'echo "coverage linker flags: -fuse-ld=lld"'
- invoke: >-
cargo llvm-cov --lcov --output-path lcov.info
--all-targets --all-features
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
RUSTFLAGS: -D warnings -C link-arg=-fuse-ld=lld
CFLAGS: -fuse-ld=lld
LDFLAGS: -fuse-ld=lld
command:
invoke: cargo test --all-targets
cwd: rust_extension
command:
- invoke: generator --format json
cwd: producer
pipe: true
- invoke: validator --schema schema.json
cwd: consumer
stdout: validated.json
Directories stay inside the workspace
A relative cwd resolves against the effective workspace after -C. Absolute paths and lexical or symlink escapes are rejected, and nothing is created on the author's behalf. In shell mode Netsuke applies the directory at spawn rather than lowering it to cd backend && ….
Deliberately small, for now
Richer operations — default, prepend, append, unset — and env at rule, target, or action level are deferred to decision 15.1.1. Whether stream paths resolve against cwd or the workspace is one of the conflicts 12.1.1 must settle; RFC 0001 and RFC 0009 currently disagree.
Roadmap 12.2 Exact overlays and literal cwd · 15.1 Additional environment surfaces
Runtime bindings and private scratch space
RFC 0010 lets one execution unit pass a discovered value to the next without a shell. A block's standard output can be captured into a named binding, and a later block can use it as its working directory. A block can also ask the runner for a private temporary directory and name it for its successors.
command:
- invoke: workspace-locator --relative
stdout:
env: WORKSPACE_DIR
- invoke: cargo test --all-targets
cwd:
env: WORKSPACE_DIR
command:
- invoke: prepare-fixture
cwd:
tempdir:
env: FIXTURE_DIR
- invoke: verify-fixture
cwd:
env: FIXTURE_DIR
Runtime bindings are scoped data owned by the action runner, not mutations of the parent process environment. Text captured from a child cannot grant filesystem authority merely by containing an absolute path. — RFC 0010
Capture
A capture takes env, chomp (default true), and max_bytes (default 65,536, at most 16 MiB). Decoding is strict UTF-8; invalid bytes or a NUL stop the commit. A binding becomes visible only after its producer succeeds, and its value is kept out of diagnostics by default. If the captured text is /tmp/outside, the next block fails before spawn: text carries no directory capability.
Temporary directories
Names are unpredictable, and the directory is owner-only (0700 on Unix, a restricted ACL on Windows) before the child sees it — never created open and tightened afterwards. Cleanup runs after success, failure, cancellation, timeout, signal, spawn error, and pipeline setup failure, and does not follow symlinks. Failed directories are not kept for debugging.
Two designs, not one. RFC 0001 also proposes a per-stage temp_dir: true that binds
TMPDIR, TMP, and TEMP; RFC 0010's cwd: { tempdir } does not rewrite those
variables. Likewise RFC 0001's ephemeral capture_stdout is distinct from stdout: { env: NAME }.
Task 12.1.1 will record whether both spellings remain.
Roadmap 14.1 Discovery results · 14.2 Temporary-directory authority · 14.3 Downstream-style proof
Streams and pipelines without a shell
Redirection and piping become fields: stdin, stdout, stderr, tee,
pipe, and capture_stdout. Output files are create-or-truncate, with append mode reserved. Pipes and
tees carry arbitrary bytes. Before anything is truncated, every destination in a unit is opened provisionally and compared,
so relative aliases, symlinks, and hard links that name the same file are caught first.
command:
- invoke: producer --format=json
pipe: true
- invoke: jq ".items[]"
pipe: true
- invoke: consumer
stdout: result.txt
command:
- invoke: compiler invalid-source.rs
pipe: stderr
- invoke: normalize-diagnostics
stdout: normalized-errors.txt
Strict pipefail, without the shell
A pipeline succeeds only when every stage exits successfully, every stream opens, and every relay or tee completes — the effect of pipefail, derived from child statuses rather than shell configuration. On failure, started stages are terminated and reaped and every relay is joined. Units run in declaration order and the first failure stops the sequence. pipe: stderr is not 2>&1: merged streams are out of scope and still need explicit shell mode.
Where legacy state stops
Pipelines never cross a rule, script, or legacy-string boundary, and a structured item ends the current legacy shell group:
command:
- export MODE=fast
- echo "$MODE" # Same legacy shell group: prints fast.
- invoke: tool
- echo "$MODE" # New legacy shell group: MODE is not retained.
Roadmap 13.1 Stream binding · 13.2 Pipelines · 13.3 Stream validation · 15.2 Append, merged streams, and more
Named, allow-listed shells
Some recipes need a shell. RFC 0011 widens shell from a Boolean to a Boolean or a name. true
resolves to sh on Unix-like hosts and powershell on Windows; a name picks one of four built-ins.
Any shell — shell: true or a named one — gives up direct mode's argv-safety. The
selector changes the interpreter, not the safety boundary.
command:
- invoke: cargo test --workspace
shell: false # direct: argv-safe
- invoke: Write-Output $env:TARGET
shell: true # platform default: shell text
- invoke: set -o pipefail; build-project
shell: bash # named: shell text
- invoke: Get-ChildItem Env:
shell: pwsh # named: shell text
| Name | Hosts | Fixed arguments |
|---|---|---|
| sh | Unix-like, /bin/sh | -c |
| bash | Unix-like and Windows | --noprofile --norc -c |
| pwsh | Unix-like and Windows | -NoLogo -NoProfile -NonInteractive -Command |
| powershell | Windows | -NoLogo -NoProfile -NonInteractive -Command |
The manifest cannot add a shell
Further shells, such as dash, come only from trusted operator configuration as [[shells]] entries; a project's .netsuke.toml is never trusted for shell definitions. Names resolve against the host PATH captured before the block's env overlay, so a manifest-supplied PATH or a repository-local executable cannot stand in for an allow-listed shell.
Today versus planned
Today's Windows route for every legacy recipe is Windows PowerShell 5.1 (powershell.exe); the security page documents it. The planned pwsh and powershell names are separate, per-block choices and change nothing for legacy recipes. Draft PR #778 also proposes reserving the YAML 1.1 Boolean words (yes, on, and the rest) so a quoted "on" cannot name a shell; that addendum is not on main.
Roadmap 11.1 Contract (done) · 11.2 Trusted resolution · 11.3 Execution boundaries
From the shallow end
Nothing existing has to move. Scalar command strings keep their shell semantics, an all-string list behaves exactly as it
does now, and no manifest is converted to direct invocation automatically. Structured mappings will sit behind an additive
manifest minor version, provisionally 1.1.0, so older Netsuke releases reject the new syntax cleanly rather than
misreading it.
-
1 · Leave it
command: "…"strings keep working unchanged. Recipes that depend on shell state should stay legacy, or useshell: trueor a named shell. -
2 · Convert one item
Lists may mix strings,
invokeblocks,rule:, andscript:items, so one line can move at a time. Migrate a legacy list only when no shell state must persist between its entries. -
3 · Replace the idioms
Swap
cdforcwd, command substitution for capture, and2>&1 |forpipe: stderrwhere merged output is not required. A migration guide with executed examples is task 14.3.2.
# Before
command: cd rust_extension && cargo clippy --all-targets
# After
command:
invoke: cargo clippy --all-targets
cwd: rust_extension
# Before
command: WORKSPACE_DIR=$(tool locate) && cd "$WORKSPACE_DIR" && cargo test
# After
command:
- invoke: tool locate --relative
stdout:
env: WORKSPACE_DIR
- invoke: cargo test
cwd:
env: WORKSPACE_DIR
The deeper end is separate work. Recipes that must be ready before they run — a virtual environment, a live service — are the subject of States and Probes, which proposes states as further execution units on this same structured runner rather than a template-time subprocess facility.
Confidence
The central claim is small enough to test: a scalar interpolation cannot change process structure or argv boundaries. The RFCs plan evidence for it at several strengths. None of that is written yet; what ships is the equivalent work for today's legacy placeholders.
Planned
- Property tests that arbitrary scalars stay inside their original argv word and sequences splice exactly their elements; 12.1.4 makes “hostile scalars never change the argument count” its success criterion.
- Property tests over bounded relative paths (RFC 0009), binding sequences where no text binding acquires directory capability (RFC 0010), and shell names against
PATH/PATHEXTstates (11.2.4). - Bounded Kani checks for command-block mutual exclusions and pipeline topology; 13.2.1 requires them over the normalization kernel, proving one selected stream per edge and at most one predecessor per input. RFC 0010 allows a harness for binding reservation, commit, failure, and cleanup.
- Fuzzing with quotes, newlines, shell operators, Unicode, and substitution text as scalar payloads, asserting the process structure is unchanged.
- A recorder helper executable that captures argv, environment, stdin, stdout, stderr, and exit status for cross-platform integration tests, plus a deterministic
EEXISTrace test and compatibility snapshots for legacy lowering.
Shipping today, for legacy recipes
ADR-027 fixes the contract for $in and $out placeholders in shell-string recipes, and is careful about what it proves.
- The placeholder marker recognizer is covered by Kani proofs.
- The backtick and
shlexbehaviour is covered by proptest, not by proof. - The
shlexguard is documented as not an injection check. Argv-safety is exactly what legacy recipes do not have, and why this design exists.
The publication gate, 14.3.3, requires formatting, lint, documentation, unit, behavioural, property, and bounded-proof gates on the supported target matrix, plus at least two downstream canaries.
Accessibility and localization
Localized diagnostics
Shell-resolution failures keep a typed classification that is converted into a localized, user-facing diagnostic at one application boundary, so behaviour never depends on message text. Diagnostics name the action, the one-based item, the pipeline stage, and the exit status.
Redaction
Diagnostics never print whole environment mappings, since argv and environments may hold credentials. Captured values stay hidden by default, complete PATH values are not printed, and absolute host paths are omitted or redacted where a relative path will do.
Where the design is silent
The RFCs and ADR-019 set no accessibility-specific requirements beyond Netsuke's shared output modes, such as the existing accessible output. Nothing more is promised here, and this page does not invent it.