Summary
spawn(cmd, args, { shell: true }) in exec.ts:120 and run.ts:93 (v0.5.1) makes Node concatenate the command and its arguments into one string for /bin/sh. Two consequences:
- Any argument containing spaces is silently re-split, so wrapped commands do not run as written — they fail quietly with exit code 0.
- Secret values can end up in the child process's
argv, where any user on the machine can read them with ps — which is the thing psst exists to prevent.
Node warns about exactly this pattern:
DEP0190: Passing args to a child process with shell option true can lead to
security vulnerabilities, as the arguments are not escaped, only concatenated.
Reproduction 1 — silent mangling
psst API_TOKEN -- sh -c 'echo hello'
Expected: hello
Actual: an empty line, exit 0
Because the final command line becomes sh -c echo hello, the inner shell runs echo with no arguments and $0=hello.
This gets worse with a realistic wrapper. Anything of the form:
psst API_TOKEN -- sh -c 'SOME_VAR=$API_TOKEN mytool run --file /tmp/script.js'
reduces to the inner shell executing a bare variable assignment as its command, so mytool is never invoked at all. It exits 0 and prints nothing, which reads like "the tool produced no output" rather than "the tool never ran". I lost a while to this before suspecting the wrapper.
The example in psst --help has the same defect:
psst STRIPE_KEY -- curl -H "Authorization: $STRIPE_KEY" https://api.stripe.com
The joined string re-splits at the space inside the header, so curl receives -H Authorization: plus a separate stray argument.
Reproduction 2 — secret visible in ps
echo 'sleep 6' > /tmp/slow.sh
export FAKE_CANARY=canary_abc123 # env fallback, no vault write needed
psst FAKE_CANARY -- sh /tmp/slow.sh '$FAKE_CANARY' &
sleep 2
ps -Ao pid,args | grep canary_abc123 | grep -v grep
Actual:
73532 sh /tmp/slow.sh canary_abc123
The outer shell expands $FAKE_CANARY while building the child's argv, so the plaintext value sits in the process table for the lifetime of that process. The quoting in the example is what a user writes so that psst — rather than their interactive shell — receives the literal $NAME, i.e. it is the documented usage pattern, not an unusual one.
Root cause
// exec.ts:117
const child = spawn(cmd, args, {
env,
stdio: shouldMask ? ["inherit", "pipe", "pipe"] : "inherit",
shell: true, // <-- concatenation + a second round of shell parsing
});
run.ts:90 is identical.
Suggested fix
Drop shell: true. Argv is then passed as an array: nothing is re-split, and no shell expands anything while constructing it.
Side-by-side check:
import { spawn } from "node:child_process";
const env = { ...process.env, SECRET: "canary_abc123" };
const shell = process.argv[2] === "shell";
const c = spawn("sh", ["-c", 'echo "child sees: $SECRET"'],
{ env, stdio: ["inherit","pipe","pipe"], shell });
c.stdout.on("data", d => process.stdout.write("out: " + d));
shell:true → out: (blank) exit 0
shell:false → out: child sees: canary_abc123 exit 0
Without shell: true the documented pattern also becomes both correct and safe, because the expansion happens inside the child shell, against a string, and never in an argv:
psst API_TOKEN -- sh -c 'curl -H "Authorization: $API_TOKEN" https://example.com'
Output masking is unaffected — it operates on the piped streams, not on how the child is spawned.
Behaviour change worth calling out
Without a shell, the command must be a real executable rather than a builtin or a string containing shell syntax (&&, ;, globs, redirections). That is the correct contract for an argv-based runner, and users who want shell semantics wrap explicitly in sh -c '...' — which, unlike today, will then actually work. Worth a line in the README and an update to the curl example.
Suggested regression tests
- an argument containing a space arrives at the child as one argument
sh -c 'echo hello' prints hello
- a secret's value never appears in
/proc/<pid>/cmdline (or ps output) of the spawned child
- masking still redacts a secret that the child prints to stdout and stderr
Happy to send a PR if useful.
Environment: psst 0.5.1 (Homebrew), macOS (arm64), Bun runtime.
Summary
spawn(cmd, args, { shell: true })inexec.ts:120andrun.ts:93(v0.5.1) makes Node concatenate the command and its arguments into one string for/bin/sh. Two consequences:argv, where any user on the machine can read them withps— which is the thing psst exists to prevent.Node warns about exactly this pattern:
Reproduction 1 — silent mangling
psst API_TOKEN -- sh -c 'echo hello'Expected:
helloActual: an empty line, exit 0
Because the final command line becomes
sh -c echo hello, the inner shell runsechowith no arguments and$0=hello.This gets worse with a realistic wrapper. Anything of the form:
psst API_TOKEN -- sh -c 'SOME_VAR=$API_TOKEN mytool run --file /tmp/script.js'reduces to the inner shell executing a bare variable assignment as its command, so
mytoolis never invoked at all. It exits 0 and prints nothing, which reads like "the tool produced no output" rather than "the tool never ran". I lost a while to this before suspecting the wrapper.The example in
psst --helphas the same defect:psst STRIPE_KEY -- curl -H "Authorization: $STRIPE_KEY" https://api.stripe.comThe joined string re-splits at the space inside the header, so curl receives
-H Authorization:plus a separate stray argument.Reproduction 2 — secret visible in
psActual:
The outer shell expands
$FAKE_CANARYwhile building the child's argv, so the plaintext value sits in the process table for the lifetime of that process. The quoting in the example is what a user writes so that psst — rather than their interactive shell — receives the literal$NAME, i.e. it is the documented usage pattern, not an unusual one.Root cause
run.ts:90is identical.Suggested fix
Drop
shell: true. Argv is then passed as an array: nothing is re-split, and no shell expands anything while constructing it.Side-by-side check:
Without
shell: truethe documented pattern also becomes both correct and safe, because the expansion happens inside the child shell, against a string, and never in an argv:psst API_TOKEN -- sh -c 'curl -H "Authorization: $API_TOKEN" https://example.com'Output masking is unaffected — it operates on the piped streams, not on how the child is spawned.
Behaviour change worth calling out
Without a shell, the command must be a real executable rather than a builtin or a string containing shell syntax (
&&,;, globs, redirections). That is the correct contract for an argv-based runner, and users who want shell semantics wrap explicitly insh -c '...'— which, unlike today, will then actually work. Worth a line in the README and an update to thecurlexample.Suggested regression tests
sh -c 'echo hello'printshello/proc/<pid>/cmdline(orpsoutput) of the spawned childHappy to send a PR if useful.
Environment: psst 0.5.1 (Homebrew), macOS (arm64), Bun runtime.