Skip to content

feat!: run commands as argv instead of through a shell - #30

Merged
refsz merged 1 commit into
mainfrom
feature/argv-exec
Aug 19, 2026
Merged

feat!: run commands as argv instead of through a shell#30
refsz merged 1 commit into
mainfrom
feature/argv-exec

Conversation

@refsz

@refsz refsz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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.

$ctx->exec(['composer', 'require', $package]);          // list  → no shell
$ctx->shell('drush sql-dump | gzip > dump.gz');          // string → real shell

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, including escapeshellarg() 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:

$ctx->shellRaw('rm -rf .cache; composer install');
// became: ddev exec rm -rf .cache; composer install
//         └─ in the container ─┘  └─ on the HOST ─┘

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

environment:
    executor: [ddev, exec]
    shell: [bash, -lc]      # optional, default [sh, -c]
exec(['drush', 'cr'])       → [ddev, exec, drush, cr]
shell('drush dump | gzip')  → [ddev, exec, sh, -c, 'drush dump | gzip']

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.

shell exists with evidence behind it: the reference project deliberately uses bash -lc, not sh -c, so PATH inside 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 used shellRaw() almost exclusively with sprintf — those call sites become exec() with an argument list, and the escaping comments in them disappear.
  • environment.executor must 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() accepts list<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 no escapeshellarg and no ShellArgumentValueFormatter: values are inserted verbatim, exactly as in render(). Masking of output is unchanged and covers both paths — a new integration test asserts a secret passed through exec() is *** in the echoed command and in the streamed output, while ExecutionResult keeps 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

vendor/bin/phpunit          730 tests, 1262 assertions, OK
vendor/bin/phpstan analyse  [OK] No errors (no baseline, no ignores)
vendor/bin/php-cs-fixer     0 of 150 files
vendor/bin/rector --dry-run [OK]
mkdocs build --strict       exit 0

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 across tasks.md, environments.md, configuration.md, recipes.md, quickstart.md and index.md.

One follow-up

CHANGELOG.md does not exist on main yet — it arrives with #28. The Unreleased entries for this change (the exec() addition, the executor list form, the shellRaw() 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

$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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant