From 636d15625ddbd1c61d6eeb822ff32b024e10ac20 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 6 Apr 2026 22:15:24 +0000 Subject: [PATCH 1/2] fix(interpreter): preserve shopt options across exec() calls reset_transient_state() was wiping ALL SHOPT_* variables between exec() calls, including shopt options like expand_aliases. This made alias expansion impossible when shopt and alias commands were in separate exec() calls (the JS/Python API pattern). Now only `set` options (SHOPT_e, SHOPT_x, etc.) are reset between exec() calls for safety (TM-ISO-023), while `shopt` options (expand_aliases, extglob, dotglob, etc.) persist as session config. Closes #1130 --- crates/bashkit/src/interpreter/mod.rs | 24 ++++++++++++++++++++-- crates/bashkit/tests/snapshot_tests.rs | 6 +++--- crates/bashkit/tests/threat_model_tests.rs | 18 ++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index cd8acada..b64fbea2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -877,13 +877,33 @@ impl Interpreter { &self.limits } + /// `set -o` option variable names (SHOPT_e, SHOPT_x, etc.) that are + /// transient and must be reset between exec() calls (TM-ISO-023). + /// `shopt` options (SHOPT_expand_aliases, SHOPT_extglob, etc.) are + /// persistent session configuration and are NOT reset. + const SET_OPTION_VARS: &'static [&'static str] = &[ + "SHOPT_a", + "SHOPT_e", + "SHOPT_f", + "SHOPT_n", + "SHOPT_u", + "SHOPT_v", + "SHOPT_x", + "SHOPT_C", + "SHOPT_pipefail", + ]; + /// THREAT[TM-ISO-005/006/007]: Reset per-exec transient state. /// Called by Bash::exec() before each top-level execution to prevent - /// traps, exit code, and shell options from leaking across calls. + /// traps, exit code, and `set` options from leaking across calls. + /// `shopt` options (expand_aliases, extglob, etc.) are intentionally + /// preserved — they are persistent session configuration. pub fn reset_transient_state(&mut self) { self.traps.clear(); self.last_exit_code = 0; - self.variables.retain(|k, _| !k.starts_with("SHOPT_")); + for var in Self::SET_OPTION_VARS { + self.variables.remove(*var); + } } /// Set an environment variable. diff --git a/crates/bashkit/tests/snapshot_tests.rs b/crates/bashkit/tests/snapshot_tests.rs index b9e43458..df1ca2c5 100644 --- a/crates/bashkit/tests/snapshot_tests.rs +++ b/crates/bashkit/tests/snapshot_tests.rs @@ -240,9 +240,9 @@ async fn shell_options_survive_snapshot_roundtrip() { let mut bash2 = Bash::new(); bash2.restore_shell_state(&restored); - // exec() calls reset_transient_state which clears SHOPT_* vars, - // so we verify the state was restored correctly by inspecting it - // before the next exec() call. + // `set` options (SHOPT_e, SHOPT_pipefail) are transient — they are + // cleared by reset_transient_state() between exec() calls (TM-ISO-023). + // Verify the snapshot restored them correctly before the next exec(). let state2 = bash2.shell_state(); assert_eq!( state2.variables.get("SHOPT_e").map(|s| s.as_str()), diff --git a/crates/bashkit/tests/threat_model_tests.rs b/crates/bashkit/tests/threat_model_tests.rs index bdb45c61..3f89c557 100644 --- a/crates/bashkit/tests/threat_model_tests.rs +++ b/crates/bashkit/tests/threat_model_tests.rs @@ -590,6 +590,24 @@ mod session_isolation { assert!(!result.stdout.contains("LEAKED")); } + /// Alias expansion must work across separate exec() calls (issue #1130). + /// shopt -s expand_aliases set in one exec() must persist to the next. + #[tokio::test] + async fn alias_expansion_persists_across_exec_calls() { + let mut bash = Bash::new(); + + bash.exec("shopt -s expand_aliases").await.unwrap(); + bash.exec("alias ll='echo alias_worked'").await.unwrap(); + + let result = bash.exec("ll").await.unwrap(); + assert_eq!( + result.exit_code, 0, + "alias should resolve: {}", + result.stderr + ); + assert_eq!(result.stdout.trim(), "alias_worked"); + } + /// TM-ISO-020: Trap handlers in one session must not fire in another #[tokio::test] async fn threat_isolation_trap_isolation() { From 3d15a767c1c4ae4ad8db179b18ed6b88cca51dd3 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 6 Apr 2026 22:17:10 +0000 Subject: [PATCH 2/2] chore(specs): mark TM-ISO-023 as fixed set options are now properly cleared between exec() calls via SET_OPTION_VARS in reset_transient_state(). --- specs/006-threat-model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index cc03bf8c..b0246f70 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -804,7 +804,7 @@ Only exact domain matches are allowed (TM-NET-017). | TM-ISO-006 | No per-instance variable/memory budget | Unbounded `HashMap` growth in variables, arrays, functions exhausts process memory | — | **OPEN** | | TM-ISO-021 | EXIT trap leaks across `exec()` calls | EXIT trap set in one `exec()` fires in subsequent `exec()` calls on same Bash instance | — | **OPEN** | | TM-ISO-022 | `$?` leaks across `exec()` calls | Exit code from one `exec()` visible as `$?` in next `exec()` instead of resetting to 0 | — | **OPEN** | -| TM-ISO-023 | `set -e` leaks across `exec()` calls | Shell options (`set -e`, etc.) persist across `exec()` calls, causing unexpected abort behavior | — | **OPEN** | +| TM-ISO-023 | `set -e` leaks across `exec()` calls | `set` options (`-e`, `-x`, etc.) persist across `exec()` calls, causing unexpected abort behavior | `reset_transient_state()` clears `SET_OPTION_VARS` | **FIXED** | **TM-ISO-004**: Fixed. The jq builtin now injects shell variables via a custom jaq context variable (`$__bashkit_env__`) and overrides the `env` filter to read from it instead of `std::env`. @@ -1224,7 +1224,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | TM-INJ-021 | `export` overwrites readonly variables | Integrity bypass — `export X=new` overwrites `readonly X=old` | Check readonly attribute in export assignment path | | 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-023 | `set -e` leaks across `exec()` calls | Unexpected abort — `set` options from previous exec affect next exec | `SET_OPTION_VARS` cleared in `reset_transient_state()` (**FIXED**) | | 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 |