From 4852316a10e500438312ec932bc1be1a9bfabc9a Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 26 Mar 2026 18:10:27 +0000 Subject: [PATCH 1/3] fix(interpreter): reset last_exit_code and nounset_error in VFS subprocess isolation Subprocess isolation in execute_script_content did not reset last_exit_code or nounset_error, causing the VFS script subprocess to inherit stale parent state. In real bash, a subprocess starts with $?=0. This caused set -euo pipefail scripts to see a non-zero $? from the parent shell, leading to false errexit failures. Closes #842 --- crates/bashkit/src/interpreter/mod.rs | 6 +- .../bashkit/tests/script_execution_tests.rs | 89 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 18ddadfb..872e02f8 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -4013,7 +4013,9 @@ impl Interpreter { let saved_aliases = self.aliases.clone(); let saved_coproc = self.coproc_buffers.clone(); - // Child only sees exported variables (env), not all shell variables + // Child only sees exported variables (env), not all shell variables. + // Reset last_exit_code so $? starts at 0 (matches real bash subprocess). + // Clear nounset_error to prevent parent expansion errors from leaking. self.variables = self.env.clone(); self.arrays.clear(); self.assoc_arrays.clear(); @@ -4021,6 +4023,8 @@ impl Interpreter { self.traps.clear(); self.aliases.clear(); self.coproc_buffers.clear(); + self.last_exit_code = 0; + self.nounset_error = None; // Push call frame: $0 = script name, $1..N = args self.call_stack = vec![CallFrame { diff --git a/crates/bashkit/tests/script_execution_tests.rs b/crates/bashkit/tests/script_execution_tests.rs index 298dd8e2..d18f6593 100644 --- a/crates/bashkit/tests/script_execution_tests.rs +++ b/crates/bashkit/tests/script_execution_tests.rs @@ -409,3 +409,92 @@ async fn declare_f_no_args_lists_all_functions() { assert!(result.stdout.contains("foo")); assert!(result.stdout.contains("bar")); } + +/// Issue #842: $? should be 0 at start of VFS script subprocess, even when +/// parent had non-zero exit code. Subprocess isolation must reset last_exit_code +/// to match real bash subprocess behavior. +#[tokio::test] +async fn exec_vfs_script_initial_exit_code_is_zero() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/check_exit.sh"), b"#!/bin/bash\necho $?\n") + .await + .unwrap(); + fs.chmod(Path::new("/check_exit.sh"), 0o755).await.unwrap(); + + // Parent has non-zero exit code, then run VFS script + let result = bash.exec("false; /check_exit.sh").await.unwrap(); + assert_eq!( + result.stdout.trim(), + "0", + "VFS script subprocess should start with $?=0 (like real bash); got stdout={:?}", + result.stdout + ); +} + +/// Issue #842: `set -euo pipefail` in VFS script should not trigger false failure +/// even after a prior non-zero exit code in the parent shell. +#[tokio::test] +async fn exec_vfs_script_set_e_after_prior_failure() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file( + Path::new("/test.sh"), + br#"#!/bin/bash +set -euo pipefail +MY_VAR="hello" +echo "${MY_VAR}" +"#, + ) + .await + .unwrap(); + fs.chmod(Path::new("/test.sh"), 0o755).await.unwrap(); + + let result = bash.exec("false; /test.sh").await.unwrap(); + assert_eq!( + result.exit_code, 0, + "VFS script with set -e should succeed after prior failure; stdout={:?}, stderr={:?}", + result.stdout, result.stderr + ); + assert_eq!(result.stdout.trim(), "hello"); +} + +/// Issue #842: Nested VFS scripts with set -e and command substitution. +#[tokio::test] +async fn exec_vfs_script_set_e_nested_scripts() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file( + Path::new("/inner.sh"), + br#"#!/bin/bash +set -euo pipefail +echo "inner_output" +"#, + ) + .await + .unwrap(); + fs.chmod(Path::new("/inner.sh"), 0o755).await.unwrap(); + + fs.write_file( + Path::new("/outer.sh"), + br#"#!/bin/bash +set -euo pipefail +RESULT="$(/inner.sh)" +echo "${RESULT}" +"#, + ) + .await + .unwrap(); + fs.chmod(Path::new("/outer.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/outer.sh").await.unwrap(); + assert_eq!( + result.exit_code, 0, + "Nested VFS scripts with set -e should work; stdout={:?}, stderr={:?}", + result.stdout, result.stderr + ); + assert_eq!(result.stdout.trim(), "inner_output"); +} From 32794cacc267377c5c8c7a067d6be2e8a08501b8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 26 Mar 2026 18:12:31 +0000 Subject: [PATCH 2/3] chore(specs): add TM-ISO-024 for $? leaking into VFS subprocess Documents the threat vector where parent last_exit_code was visible inside VFS script subprocesses, causing false set -e failures. --- specs/006-threat-model.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index 0ddef7ea..71d317e2 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -1207,6 +1207,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | TM-ISO-021 | EXIT trap leaks across `exec()` calls | Cross-invocation interference — trap from exec N fires in exec N+1 | Reset traps in `reset_for_execution()` | | TM-ISO-022 | `$?` leaks across `exec()` calls | State pollution — exit code from previous exec visible to next exec | Reset `last_exit_code` in `reset_for_execution()` | | TM-ISO-023 | `set -e` leaks across `exec()` calls | Unexpected abort — shell options from previous exec affect next exec | Reset shell options in `reset_for_execution()` | +| TM-ISO-024 | `$?` leaks into VFS subprocess | Parent `last_exit_code` visible inside VFS script subprocess, causing false `set -e` failures | Reset `last_exit_code = 0` and `nounset_error = None` in `execute_script_content` subprocess isolation | | TM-INT-007 | `/dev/urandom` empty with `head -c` | Weak randomness — `head -c 16 /dev/urandom` returns empty string | Fix virtual device pipe handling in head builtin | | TM-DOS-044 | Nested `$()` stack overflow (regression) | Process crash (SIGABRT) at depth ~50 despite #492 fix | Interpreter execution path may need separate depth tracking from lexer fix | From 136eb660458656cb5dd24858e5b09b58cf870d5b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 26 Mar 2026 18:23:42 +0000 Subject: [PATCH 3/3] chore: bump cmake cargo-vet exemption to 0.1.58 --- supply-chain/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 62eab192..020039d2 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -207,7 +207,7 @@ version = "1.1.0" criteria = "safe-to-deploy" [[exemptions.cmake]] -version = "0.1.57" +version = "0.1.58" criteria = "safe-to-deploy" [[exemptions.cobs]]