tests/e2e/vm-env-secrets.test.sh passes on 61b3a87 while atomic-vm.sh test-exec puts every provider API key into the ssh command line. #299 fixed that shape in cmd_run and cmd_provision. cmd_test_exec was never converted, and the gate cannot see it.
The live exposure
tests/e2e/atomic-vm.sh:509-526 builds an accumulator and interpolates it into the command string:
local env_prefix=""
for var in SYSKNIFE_ALLOW_DESTRUCTIVE ... \
OPENAI_API_KEY ANTHROPIC_API_KEY GEMINI_API_KEY \
GROQ_API_KEY DEEPSEEK_API_KEY MISTRAL_API_KEY XAI_API_KEY; do
eval "val=\${$var:-}"
if [ -n "$val" ]; then
env_prefix+=" $var='$val'"
fi
done
cmd_ssh "cd /home/${VM_USER}/sysknife &&${env_prefix} bash tests/e2e/exec/run-exec-stories.sh${story_args}"
Extracting cmd_test_exec from the unmodified file and stubbing cmd_ssh to print its own argument shows what ssh receives. Every provider key in the caller's environment was cleared first except a planted placeholder:
$ env -u ANTHROPIC_API_KEY -u GEMINI_API_KEY -u GROQ_API_KEY -u DEEPSEEK_API_KEY \
-u MISTRAL_API_KEY -u XAI_API_KEY bash inject-safe.sh
ssh argv[1] = cd /home/fedora/sysknife && SYSKNIFE_LLM_MODEL='gpt-4o'; id #' OPENAI_API_KEY='sk-PLACEHOLDER-000' bash tests/e2e/exec/run-exec-stories.sh
Two things in that one line, and both are what #299's commit message says it closed:
- The key value sits in the command string, so it lands in the guest process table for the length of the story run. Any account on the VM can read it out of
/proc.
env_prefix+=" $var='$val'" wraps the value in single quotes without escaping. I set SYSKNIFE_LLM_MODEL to gpt-4o'; id #, the quote closed early, and id became a separate command on the guest. cmd_run avoids this by using printf %q; cmd_test_exec does not.
The function reads the keys straight out of the ambient environment, so anything that captures that command string captures live credentials: a set -x trace, a CI log, a shell history file.
Why the gate stays green
tests/e2e/vm-env-secrets.test.sh:46-49 is the structural check:
if grep -nE 'cmd_ssh[^#]*sudo *[$"]?\$\{?[A-Za-z_]' "$harness" \
| grep -vE 'sudo (sh|bash) -c' >/dev/null; then
It requires the word sudo, and it only inspects the token immediately after it. cmd_test_exec runs as VM_USER on purpose, because group membership decides the daemon's CallerRole and root holds no sysknife groups, so the line carries no sudo at all. Run by hand on the current tree:
$ grep -nE 'cmd_ssh[^#]*sudo *[$"]?\$\{?[A-Za-z_]' tests/e2e/atomic-vm.sh
$ echo $?
1
The key-name check at line 53 misses it too, because the line names ${env_prefix} rather than any *_API_KEY.
Two more holes in the same file, both found by mutation:
The harness list is hand-written and never compared against the tree.
harnesses=(
"tests/e2e/atomic-vm.sh"
"tests/e2e/ubuntu-vm.sh"
)
I copied ubuntu-vm.sh to fedora-vm.sh with export OPENAI_API_KEY=${OPENAI_API_KEY} spliced onto its cmd_ssh line. The gate reported success and did not read the file:
$ bash tests/e2e/vm-env-secrets.test.sh
VM environment secrets are stdin-only across 2 harnesses.
Emptying the array does the same thing, at rc=0, printing across 0 harnesses.
Line 53 runs a short-circuiting reader on the right of a pipe under pipefail.
if grep -n 'cmd_ssh' "$harness" | grep -Fq "$key"; then
grep -Fq exits on the first match, the producer takes SIGPIPE, and pipefail turns a real hit into a miss. On the current harnesses the producer writes 619 and 829 bytes, so it never fires. Past the pipe buffer it stops working:
producer output 24217 bytes -> 100/100 hits reported
producer output 48617 bytes -> 55/100 hits reported
producer output 73420 bytes -> 21/100 hits reported
producer output 98620 bytes -> 7/100 hits reported
Latent today. Worth closing while the file is open.
Patch
Two files, and they have to land together: the gate goes red on main until the harness is fixed, which is the point.
The gate now discovers harnesses with grep -l '^cmd_ssh()' tests/e2e/*.sh and fails when that selects fewer than two. It reads every variable interpolated into any cmd_ssh command string and requires each to be on a declared allowlist (VM_USER, GUEST_ENV_FILE, story_args), so an accumulator fails whatever it is called and wherever it sits in the line. It checks every stdin transfer for rm -f, umask 077 and cat > rather than one literal. The two grep | grep -Fq pipes become here-strings.
The harness change routes cmd_test_exec through the same stdin path as cmd_run. One decision is yours rather than mine: the existing write_guest_env_file writes a root-owned 0600 file, and this consumer runs as VM_USER and cannot read it. I added a write_guest_env_file_for_user variant that chowns the file to VM_USER after writing it. Handing the file to a non-root account is a real widening, small, and I would rather you looked at it than had it arrive silently.
vm-env-secrets-argv.patch
diff --git a/tests/e2e/atomic-vm.sh b/tests/e2e/atomic-vm.sh
index 087ebb2..84d316e 100755
--- a/tests/e2e/atomic-vm.sh
+++ b/tests/e2e/atomic-vm.sh
@@ -117,6 +117,25 @@ ssh_opts() {
log() { printf '[atomic-vm] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
+write_guest_env_file_for_user() {
+ # cmd_test_exec runs as VM_USER on purpose: group membership decides the
+ # daemon's CallerRole, and root holds no sysknife groups. A root-owned 0600
+ # file is unreadable by that consumer, so this variant hands the file to
+ # VM_USER. Same stdin transfer, same mode, same fresh inode.
+ local assignment value var
+ local assignments=()
+ for var in "$@"; do
+ eval "value=\${$var-}"
+ if [ -n "$value" ]; then
+ printf -v assignment '%s=%q' "$var" "$value"
+ assignments+=("$assignment")
+ fi
+ done
+
+ printf '%s\n' "${assignments[@]}" \
+ | cmd_ssh "sudo sh -c 'rm -f ${GUEST_ENV_FILE} && umask 077 && cat > ${GUEST_ENV_FILE} && chown ${VM_USER} ${GUEST_ENV_FILE}'"
+}
+
write_guest_env_file() {
local assignment value var
local assignments=()
@@ -506,24 +525,19 @@ cmd_test_exec() {
# Run as VM_USER (not root) so sysknife connects to the daemon socket with
# the user's group membership (sysknife/sysknife-dev/sysknife-admin) and
# gets the correct CallerRole. Root has no sysknife groups and gets Observer.
- local env_prefix=""
- for var in SYSKNIFE_ALLOW_DESTRUCTIVE SYSKNIFE_LLM_PROVIDER SYSKNIFE_LLM_MODEL \
- SYSKNIFE_TEST_MODEL SYSKNIFE_OLLAMA_URL SYSKNIFE_SOCKET SYSKNIFE_LISTEN_URI \
- SYSKNIFE_STORY_TIMEOUT SYSKNIFE_MAX_RPM \
- SYSKNIFE_CASSETTE SYSKNIFE_CASSETTE_MODE \
- OPENAI_API_KEY ANTHROPIC_API_KEY GEMINI_API_KEY \
- GROQ_API_KEY DEEPSEEK_API_KEY MISTRAL_API_KEY XAI_API_KEY; do
- eval "val=\${$var:-}"
- if [ -n "$val" ]; then
- env_prefix+=" $var='$val'"
- fi
- done
+ write_guest_env_file_for_user \
+ SYSKNIFE_ALLOW_DESTRUCTIVE SYSKNIFE_LLM_PROVIDER SYSKNIFE_LLM_MODEL \
+ SYSKNIFE_TEST_MODEL SYSKNIFE_OLLAMA_URL SYSKNIFE_SOCKET SYSKNIFE_LISTEN_URI \
+ SYSKNIFE_STORY_TIMEOUT SYSKNIFE_MAX_RPM \
+ SYSKNIFE_CASSETTE SYSKNIFE_CASSETTE_MODE \
+ OPENAI_API_KEY ANTHROPIC_API_KEY GEMINI_API_KEY \
+ GROQ_API_KEY DEEPSEEK_API_KEY MISTRAL_API_KEY XAI_API_KEY
local story_args=""
if [ $# -gt 0 ]; then
story_args=" $*"
fi
- cmd_ssh "cd /home/${VM_USER}/sysknife &&${env_prefix} bash tests/e2e/exec/run-exec-stories.sh${story_args}"
+ cmd_ssh "cd /home/${VM_USER}/sysknife && bash -c 'set -e; trap \"rm -f ${GUEST_ENV_FILE}\" EXIT; set -a; . ${GUEST_ENV_FILE}; set +a; rm -f ${GUEST_ENV_FILE}; trap - EXIT; exec bash tests/e2e/exec/run-exec-stories.sh${story_args}'"
}
cmd_snapshot() {
diff --git a/tests/e2e/vm-env-secrets.test.sh b/tests/e2e/vm-env-secrets.test.sh
index 701ec37..5626931 100755
--- a/tests/e2e/vm-env-secrets.test.sh
+++ b/tests/e2e/vm-env-secrets.test.sh
@@ -4,10 +4,19 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
config_rs="$repo_root/crates/sysknife-brain/src/config.rs"
-harnesses=(
- "tests/e2e/atomic-vm.sh"
- "tests/e2e/ubuntu-vm.sh"
-)
+
+# Discover the harnesses instead of listing them. The hand-written list held
+# two entries and nothing compared it against the tree, so a third harness --
+# or an emptied list -- left this gate reporting success over zero files.
+harnesses=()
+while IFS= read -r path; do
+ harnesses+=("${path#"$repo_root"/}")
+done < <(grep -l '^cmd_ssh()' "$repo_root"/tests/e2e/*.sh | sort)
+if [ "${#harnesses[@]}" -lt 2 ]; then
+ printf 'harness discovery found %d file(s) defining cmd_ssh; expected at least 2\n' \
+ "${#harnesses[@]}" >&2
+ exit 1
+fi
keys=()
while IFS= read -r key; do
@@ -18,6 +27,12 @@ if [ "${#keys[@]}" -lt 7 ]; then
exit 1
fi
+# Variables that may appear inside a command string handed to cmd_ssh. Each is
+# a path or an argument list, never a credential. Anything else interpolated
+# into that string reaches the guest command line, so a new name has to be
+# added here deliberately rather than passing by default.
+allowed_vars=(VM_USER GUEST_ENV_FILE story_args)
+
failures=0
report() {
printf 'FAIL %s\n' "$1" >&2
@@ -27,30 +42,63 @@ report() {
for rel in "${harnesses[@]}"; do
harness="$repo_root/$rel"
- [ "$(grep -c '^[[:space:]]*write_guest_env_file \\' "$harness")" -eq 2 ] \
+ [ "$(grep -c '^[[:space:]]*write_guest_env_file \\' "$harness")" -ge 2 ] \
|| report "$rel must stage environment for both provision and run"
- grep -Fq "| cmd_ssh \"sudo sh -c 'rm -f \${GUEST_ENV_FILE} && umask 077 && cat > \${GUEST_ENV_FILE}'\"" "$harness" \
- || report "$rel does not transfer the environment over SSH stdin into a fresh 0600 file"
- [ "$(grep -c 'rm -f \${GUEST_ENV_FILE}.*exec bash tests/e2e/' "$harness")" -eq 2 ] \
+ # Every stdin transfer, not one spelling of one of them. Anchoring on a
+ # single literal meant a second transfer added later (the VM-user variant)
+ # inherited no check at all: dropping its `umask 077` left this green.
+ transfers="$(grep -n '| cmd_ssh "sudo sh -c' "$harness" || true)"
+ if [ -z "$transfers" ]; then
+ report "$rel has no stdin environment transfer at all"
+ fi
+ while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ for required in 'rm -f ${GUEST_ENV_FILE}' 'umask 077' 'cat > ${GUEST_ENV_FILE}'; do
+ grep -Fq "$required" <<<"$line" \
+ || report "$rel:${line%%:*} transfers the environment without '$required', so the guest file is not a fresh 0600 one"
+ done
+ done <<<"$transfers"
+ [ "$(grep -c 'rm -f \${GUEST_ENV_FILE}.*exec bash tests/e2e/' "$harness")" -ge 2 ] \
|| report "$rel must remove the guest environment before both scripts execute"
# The regression to catch is a secret value reaching argv, not one
- # particular way of spelling it. The previous form matched the old
- # accumulator by name (`prov_env`, `sudo_env`), so reintroducing the same
- # bug under any other variable name passed silently.
+ # particular way of spelling it. Matching the accumulator by name
+ # (`prov_env`, `sudo_env`) let the same bug back in under a new name, and
+ # the structural replacement only inspected the token immediately after
+ # `sudo` -- so a call site that runs as the VM user, with no `sudo` at all,
+ # was never examined. That is where the bug actually came back.
#
- # Structural instead: no line that invokes cmd_ssh may interpolate a
- # shell variable straight after `sudo`, which is the shape that puts a
- # value on the guest command line. The stdin transfer and the `sudo bash
- # -c` consumer both name a literal command after sudo, so neither matches.
- if grep -nE 'cmd_ssh[^#]*sudo *[$"]?\$\{?[A-Za-z_]' "$harness" \
- | grep -vE 'sudo (sh|bash) -c' >/dev/null; then
- report "$rel interpolates a variable into an argv command after sudo"
+ # Allowlist instead: read every variable interpolated into a cmd_ssh
+ # command string and require each to be a declared non-secret. A new
+ # accumulator fails whatever it is called and wherever it sits in the line.
+ # An empty selection here would screen nothing and say nothing, which is
+ # the defect this whole file exists to catch. Require it to select.
+ cmd_ssh_lines="$(grep -n 'cmd_ssh' "$harness" || true)"
+ cmd_ssh_calls="$(grep -nE 'cmd_ssh "' "$harness" || true)"
+ if [ -z "$cmd_ssh_calls" ]; then
+ report "$rel: no cmd_ssh call site matched, so the argv checks below screened nothing"
+ continue
fi
+ while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ lineno="${line%%:*}"
+ while IFS= read -r var; do
+ [ -n "$var" ] || continue
+ for ok in "${allowed_vars[@]}"; do
+ [ "$var" = "$ok" ] && continue 2
+ done
+ report "$rel:$lineno interpolates \$$var into a cmd_ssh command string, which puts its value in argv; stage it with write_guest_env_file or add it to allowed_vars"
+ done < <(printf '%s\n' "$line" | grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*' | tr -d '${' | sort -u)
+ done <<<"$cmd_ssh_calls"
# And the specific values must never appear on a cmd_ssh line at all.
+ # `grep -n ... | grep -Fq` put a short-circuiting reader on the right of a
+ # pipe under `pipefail`: once the producer's output passes the pipe buffer
+ # it takes SIGPIPE, the pipeline reports 141, and a real hit reads as a
+ # miss. Measured at 0/300 hits on a 227 KB producer. A here-string has no
+ # pipe to break.
for key in "${keys[@]}"; do
- if grep -n 'cmd_ssh' "$harness" | grep -Fq "$key"; then
+ if grep -Fq "$key" <<<"$cmd_ssh_lines"; then
report "$rel names $key on a cmd_ssh line, which puts it in argv"
fi
done
@@ -66,7 +114,7 @@ for rel in "${harnesses[@]}"; do
continue
fi
for key in "${keys[@]}"; do
- printf '%s' "$run_block" | grep -Fq "$key" \
+ grep -Fq "$key" <<<"$run_block" \
|| report "$rel does not forward $key from cmd_run"
done
done
Mutation proof
Baseline green on a fixed tree, then thirteen mutations, each applied alone in a git clone --no-hardlinks scratch copy and restored afterwards.
| # |
Mutation |
Patched gate |
| N0 |
none, fixed tree |
rc=0 |
| N1 |
env_prefix accumulator restored (the live bug) |
rc=1, names atomic-vm.sh:526 |
| N2 |
same bug, variable renamed carried_over |
rc=1 |
| N3 |
accumulator buried mid-string inside sudo bash -c |
rc=1 |
| N4 |
third harness lands carrying the bug |
rc=1, two failures |
| N5 |
discovery selects zero harnesses |
rc=1, found 0 file(s) defining cmd_ssh |
| N6 |
key dropped from cmd_run |
rc=1 |
| N7 |
config.rs parked, key extraction reads nothing |
rc=1, found only 0 keys |
| N8 |
umask 077 dropped from the VM-user transfer |
rc=1 |
| N9 |
cmd_run renamed, block extraction returns nothing |
rc=1 |
| N10 |
rm -f dropped from the root transfer |
rc=1 |
| N11 |
every stdin transfer removed |
rc=1, has no stdin environment transfer at all |
| N12 |
every cmd_ssh call site respelled, so the argv selection is empty |
rc=1, no cmd_ssh call site matched, so the argv checks below screened nothing |
N5, N7, N9, N11 and N12 break the guard's own input rather than the code it guards. Two of them caught defects in my own drafts. N8 found a second transfer I had added and left uncovered by the single-literal assertion, which is how that assertion became a loop. N12 found the argv check reading an unguarded process substitution, screening nothing in silence if the selection came back empty, which is the same shape as the harness list it replaces.
The current gate, for comparison, passes N1, N2, N3, N4 and the emptied-list case.
Against the unmodified tree at 61b3a87:
$ bash tests/e2e/vm-env-secrets.test.sh
FAIL tests/e2e/atomic-vm.sh:526 interpolates $env_prefix into a cmd_ssh command string, which puts its value in argv; stage it with write_guest_env_file or add it to allowed_vars
1 VM environment secret handling failure(s).
rc=1
bash -n clean, shellcheck --severity=warning clean on 0.10.0, and git apply --check applies to 61b3a87 without fuzz.
If you take this
Start by reproducing the argv line above: extract cmd_test_exec with awk '/^cmd_test_exec\(\)/,/^}/', stub cmd_ssh to print $1, and clear your own provider keys first so nothing real reaches your terminal. Once you can see the secret in the string, the rest of the change follows the shape cmd_run already uses.
Related: #299 (the original fix), #387 (the same coverage-versus-behaviour split in the secret scanner).
tests/e2e/vm-env-secrets.test.shpasses on61b3a87whileatomic-vm.sh test-execputs every provider API key into the ssh command line. #299 fixed that shape incmd_runandcmd_provision.cmd_test_execwas never converted, and the gate cannot see it.The live exposure
tests/e2e/atomic-vm.sh:509-526builds an accumulator and interpolates it into the command string:Extracting
cmd_test_execfrom the unmodified file and stubbingcmd_sshto print its own argument shows what ssh receives. Every provider key in the caller's environment was cleared first except a planted placeholder:Two things in that one line, and both are what #299's commit message says it closed:
/proc.env_prefix+=" $var='$val'"wraps the value in single quotes without escaping. I setSYSKNIFE_LLM_MODELtogpt-4o'; id #, the quote closed early, andidbecame a separate command on the guest.cmd_runavoids this by usingprintf %q;cmd_test_execdoes not.The function reads the keys straight out of the ambient environment, so anything that captures that command string captures live credentials: a
set -xtrace, a CI log, a shell history file.Why the gate stays green
tests/e2e/vm-env-secrets.test.sh:46-49is the structural check:It requires the word
sudo, and it only inspects the token immediately after it.cmd_test_execruns asVM_USERon purpose, because group membership decides the daemon'sCallerRoleand root holds no sysknife groups, so the line carries nosudoat all. Run by hand on the current tree:The key-name check at line 53 misses it too, because the line names
${env_prefix}rather than any*_API_KEY.Two more holes in the same file, both found by mutation:
The harness list is hand-written and never compared against the tree.
harnesses=( "tests/e2e/atomic-vm.sh" "tests/e2e/ubuntu-vm.sh" )I copied
ubuntu-vm.shtofedora-vm.shwithexport OPENAI_API_KEY=${OPENAI_API_KEY}spliced onto itscmd_sshline. The gate reported success and did not read the file:Emptying the array does the same thing, at rc=0, printing
across 0 harnesses.Line 53 runs a short-circuiting reader on the right of a pipe under
pipefail.grep -Fqexits on the first match, the producer takes SIGPIPE, andpipefailturns a real hit into a miss. On the current harnesses the producer writes 619 and 829 bytes, so it never fires. Past the pipe buffer it stops working:Latent today. Worth closing while the file is open.
Patch
Two files, and they have to land together: the gate goes red on
mainuntil the harness is fixed, which is the point.The gate now discovers harnesses with
grep -l '^cmd_ssh()' tests/e2e/*.shand fails when that selects fewer than two. It reads every variable interpolated into anycmd_sshcommand string and requires each to be on a declared allowlist (VM_USER,GUEST_ENV_FILE,story_args), so an accumulator fails whatever it is called and wherever it sits in the line. It checks every stdin transfer forrm -f,umask 077andcat >rather than one literal. The twogrep | grep -Fqpipes become here-strings.The harness change routes
cmd_test_execthrough the same stdin path ascmd_run. One decision is yours rather than mine: the existingwrite_guest_env_filewrites a root-owned 0600 file, and this consumer runs asVM_USERand cannot read it. I added awrite_guest_env_file_for_uservariant that chowns the file toVM_USERafter writing it. Handing the file to a non-root account is a real widening, small, and I would rather you looked at it than had it arrive silently.vm-env-secrets-argv.patchMutation proof
Baseline green on a fixed tree, then thirteen mutations, each applied alone in a
git clone --no-hardlinksscratch copy and restored afterwards.env_prefixaccumulator restored (the live bug)atomic-vm.sh:526carried_oversudo bash -cfound 0 file(s) defining cmd_sshcmd_runconfig.rsparked, key extraction reads nothingfound only 0 keysumask 077dropped from the VM-user transfercmd_runrenamed, block extraction returns nothingrm -fdropped from the root transferhas no stdin environment transfer at allcmd_sshcall site respelled, so the argv selection is emptyno cmd_ssh call site matched, so the argv checks below screened nothingN5, N7, N9, N11 and N12 break the guard's own input rather than the code it guards. Two of them caught defects in my own drafts. N8 found a second transfer I had added and left uncovered by the single-literal assertion, which is how that assertion became a loop. N12 found the argv check reading an unguarded process substitution, screening nothing in silence if the selection came back empty, which is the same shape as the harness list it replaces.
The current gate, for comparison, passes N1, N2, N3, N4 and the emptied-list case.
Against the unmodified tree at
61b3a87:bash -nclean,shellcheck --severity=warningclean on 0.10.0, andgit apply --checkapplies to61b3a87without fuzz.If you take this
Start by reproducing the argv line above: extract
cmd_test_execwithawk '/^cmd_test_exec\(\)/,/^}/', stubcmd_sshto print$1, and clear your own provider keys first so nothing real reaches your terminal. Once you can see the secret in the string, the rest of the change follows the shapecmd_runalready uses.Related: #299 (the original fix), #387 (the same coverage-versus-behaviour split in the secret scanner).