feat!: run commands as argv instead of through a shell - #30
Merged
Conversation
$ctx->exec(['composer', 'require', $package]) hands the program and its
arguments to the operating system directly. No shell parses the result, so
argument boundaries survive by construction and placeholders are substituted
per element, verbatim - there is nothing to escape. shell() stays for pipes,
redirects and globs, where a shell is the point.
environment.executor becomes a list and is prepended rather than substituted
into a template, so {command} is gone. A shell() command inside a container
becomes the final argument of a shell invocation (sh -c by default, or
environment.shell), which is itself just argv - that is why one executor
covers both forms.
This closes a defect that task authors could not: a command string reached the
executor unquoted, so shellRaw('rm -rf x; composer install') in a container
task ran the second half on the HOST, because the host shell split at the
semicolon. With argv prepending that is structurally impossible, and the
hand-rolled `bash -lc ' . escapeshellarg(...)` workaround that container tasks
needed becomes unnecessary.
BREAKING CHANGE: shellRaw() is removed - use exec() for programs, or shell()
with escaped braces for a literal placeholder. environment.executor must be a
list; the {command} placeholder is no longer supported.
refsz
force-pushed
the
feature/argv-exec
branch
from
August 19, 2026 18:06
ad5ede0 to
6182970
Compare
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every command Sputnik ran was assembled as a string and handed to a shell, which then had to infer argument boundaries from spaces and quotes. That is the root cause behind
escapeshellarg, the double-escaping dance in container tasks, and one outright defect. This adds the primitive that removes the guessing instead of getting better at it.The type is the mode. A list goes to the operating system directly, so a value containing spaces, quotes or a semicolon is one argument and nothing else. Placeholders are substituted per element and inserted verbatim — no shell reads them, so nothing is escaped.
shell()keeps today's behaviour, includingescapeshellarg()on interpolated values, because there a shell does read the result.The defect this closes
EnvironmentDetector::wrapCommand()substituted the command into a template without quoting. In a container task:The host shell split at the semicolon and ran the second half on the wrong machine. The task author could not prevent it — Sputnik assembled the final string. With argv prepending it is not fixed but structurally impossible, and an end-to-end test through the real binary pins it:
exec(['printf', '[%s]', 'a; echo pwned'])must print[a; echo pwned]and never[a].Container wrapping becomes prepending
The second line is why one executor suffices rather than two config values: running a shell inside the container is itself an argv invocation — a program with two arguments, the last being the whole command string. Nothing needs quoting because that string never passes through an outer shell. The
{command}placeholder disappears.shellexists with evidence behind it: the reference project deliberately usesbash -lc, notsh -c, soPATHinside the container includes composer and npm.Breaking changes, deliberate pre-1.0
shellRaw()removed. Its purpose was "a string without interpolation";exec()covers the common case and a literal{{is written\{\{. The reference project usedshellRaw()almost exclusively withsprintf— those call sites becomeexec()with an argument list, and the escaping comments in them disappear.environment.executormust be a list. The string form with{command}is rejected by config validation with a message naming the key, rather than silently misbehaving.ExecutorInterface::execute()acceptslist<string>|string. The options shape moved into the interface, which removed a pre-existing PHPStan variance suppression — one fewer ignore in the codebase.Consequence for secret masking
On the
exec()path there is no shell, so noescapeshellargand noShellArgumentValueFormatter: values are inserted verbatim, exactly as inrender(). Masking of output is unchanged and covers both paths — a new integration test asserts a secret passed throughexec()is***in the echoed command and in the streamed output, whileExecutionResultkeeps the raw value. This is worth stating plainly: it means the shell-escaping half of #26 addressed a symptom whose cause was the string-through-shell design.Verification
New tests: argv runs without a shell (proven with a
;and a$(…)argument arriving verbatim), a string still pipes, wrapping in both forms, per-element interpolation without escaping, the empty-list rejection, secret masking on the exec path, and the semicolon regression end to end. Docs rewritten acrosstasks.md,environments.md,configuration.md,recipes.md,quickstart.mdandindex.md.One follow-up
CHANGELOG.mddoes not exist onmainyet — it arrives with #28. TheUnreleasedentries for this change (theexec()addition, the executor list form, theshellRaw()removal, the host-leak fix) still need to be added once #28 is merged; the text is drafted in this PR's commit message.🤖 Generated with Claude Code
https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A