From 7e574b68e7f88767ac66b0550bdb3792219f5413 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 8 Oct 2026 01:40:28 +0000 Subject: [PATCH] feat(interpreter): child shells count SHLVL like bash bash and sh children raise the inherited SHLVL by one, apply bash's non-numeric, negative and >999 rules (with its warning), and export it. A child's output no longer streams past the bash command's own output redirects. The spec comparison runs real bash without the runner's SHLVL. --- crates/bashkit/src/interpreter/mod.rs | 78 ++++++++++++++++++- crates/bashkit/tests/integration/main.rs | 1 + .../bashkit/tests/integration/shlvl_tests.rs | 63 +++++++++++++++ .../bashkit/tests/integration/spec_runner.rs | 3 + .../tests/spec_cases/bash/shlvl.test.sh | 62 +++++++++++++++ knowledge/log.md | 2 + knowledge/operations/limitations.md | 2 +- 7 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 crates/bashkit/tests/integration/shlvl_tests.rs create mode 100644 crates/bashkit/tests/spec_cases/bash/shlvl.test.sh diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 5f5d1602d..c5b3645a2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5505,7 +5505,31 @@ impl Interpreter { let child_snapshot = self.snapshot_subshell_state(); // A new shell starts at subshell level 0. self.bash_subshell = 0; - self.reset_state_for_child_shell(); + let shlvl_warning = self.reset_state_for_child_shell(); + // Output the `bash` command's own redirects will route must not stream + // from the child first (`bash -c 'echo x >&2' 2>/dev/null` printed x), + // as for functions and compounds. + let has_output_redirect = redirects.iter().any(|r| { + !matches!( + r.kind, + RedirectKind::Input | RedirectKind::HereDoc | RedirectKind::HereString + ) + }); + let saved_callback = if has_output_redirect { + self.output_callback.take() + } else { + None + }; + // The warning is the child's first output, so a streaming caller sees + // it before anything the child prints. + if let Some(ref warning) = shlvl_warning { + let before = self.output_emit_count; + self.maybe_emit_output( + &crate::StreamData::new(), + &crate::StreamData::from(warning.clone()), + before, + ); + } // Push call frame, apply options, execute, restore, pop self.call_stack.push(CallFrame { @@ -5556,6 +5580,9 @@ impl Interpreter { // Restore stdin self.pipeline_stdin = saved_stdin; + if let Some(cb) = saved_callback { + self.output_callback = Some(cb); + } self.pop_call_frame(); @@ -5566,16 +5593,44 @@ impl Interpreter { self.child_shell_depth -= 1; match result { - Ok(exec_result) => self.apply_redirections(exec_result, redirects).await, + Ok(mut exec_result) => { + if let Some(warning) = shlvl_warning { + exec_result.stderr = + crate::StreamData::from(warning + &exec_result.stderr.text_lossy()); + } + self.apply_redirections(exec_result, redirects).await + } Err(e) => Err(e), } } + /// `$SHLVL` for a shell started from one whose level is `parent`, following + /// bash's `adjust_shell_level`: a level that is not a number counts as 0, a + /// negative one lands on 0 rather than counting up from below, and a level + /// that would pass 999 restarts at 1 with a warning (so a script that runs + /// itself cannot drive the number up forever). + fn child_shell_level(parent: Option<&str>) -> (String, Option) { + let parent: i64 = parent + .map(str::trim) + .and_then(|text| text.parse().ok()) + .unwrap_or(0); + let next = if parent < 0 { 0 } else { parent + 1 }; + if next > 999 { + return ( + "1".to_string(), + Some(format!( + "bash: warning: shell level ({next}) too high, resetting to 1\n" + )), + ); + } + (next.to_string(), None) + } + /// Reset interpreter state to what a freshly-forked `bash`/`sh` child /// would see: drop arrays/assoc_arrays/functions/aliases/namerefs, and /// keep only exported scalars in `variables`. The caller is expected to /// have just taken a snapshot to undo this on return. See issue #1777. - fn reset_state_for_child_shell(&mut self) { + fn reset_state_for_child_shell(&mut self) -> Option { let exported_names: Vec = self .scoped .var_attrs @@ -5620,7 +5675,23 @@ impl Interpreter { } // A new shell starts getopts afresh. next_vars.insert("OPTIND".to_string(), "1".to_string()); + // A new shell counts itself: the child's level is one above the one it + // inherited, and it is exported so a grandchild counts from there. + // The inherited value is what the child would read: an exported + // variable, else the environment (where `SHLVL=n bash` puts it), else + // the parent's own level. bash exports SHLVL at startup; bashkit's + // synthetic startup variables are not marked exported, so the parent's + // plain variable stands in for that export. + let inherited = next_vars + .get("SHLVL") + .or_else(|| self.env.get("SHLVL")) + .or_else(|| self.scoped.variables.get("SHLVL")) + .map(String::as_str); + let (level, warning) = Self::child_shell_level(inherited); + next_vars.insert("SHLVL".to_string(), level.clone()); *self.vars_mut() = next_vars; + self.add_var_attr("SHLVL", VarAttrs::EXPORT); + self.env_mut().insert("SHLVL".to_string(), level); self.arrays_mut().clear(); self.assoc_arrays_mut().clear(); self.functions_mut().clear(); @@ -5630,6 +5701,7 @@ impl Interpreter { self.scoped.aliases = Arc::new(HashMap::new()); // Reset SHOPT_* flag bitfield so options from the parent don't leak. self.flags = BashFlags::empty(); + warning } } diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index c358e32f0..2eedbf8cd 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -127,6 +127,7 @@ pub mod security_audit_pocs; mod sed_fuzz_scaffold_tests; mod sed_resource_tests; pub mod set_e_and_or_tests; +pub mod shlvl_tests; pub mod shuf_resource_tests; pub mod skills_tests; pub mod snapshot_fixture_tests; diff --git a/crates/bashkit/tests/integration/shlvl_tests.rs b/crates/bashkit/tests/integration/shlvl_tests.rs new file mode 100644 index 000000000..6b5885447 --- /dev/null +++ b/crates/bashkit/tests/integration/shlvl_tests.rs @@ -0,0 +1,63 @@ +//! `$SHLVL` in child shells: the warning bash prints when the level would pass +//! 999, and that a child's stderr honors the `bash` command's own redirects. +//! Stdout behavior lives in `spec_cases/bash/shlvl.test.sh`. + +use bashkit::Bash; + +#[tokio::test] +async fn shlvl_too_high_warns_and_resets() { + let mut bash = Bash::new(); + let r = bash + .exec("SHLVL=999 bash -c 'echo \"g=$SHLVL\"; true'") + .await + .unwrap(); + assert_eq!(r.stdout, "g=1\n"); + assert_eq!( + r.stderr, + "bash: warning: shell level (1000) too high, resetting to 1\n" + ); +} + +#[tokio::test] +async fn shlvl_in_range_does_not_warn() { + let mut bash = Bash::new(); + let r = bash + .exec("SHLVL=998 bash -c 'echo \"g=$SHLVL\"; true'") + .await + .unwrap(); + assert_eq!(r.stdout, "g=999\n"); + assert_eq!(r.stderr, ""); +} + +#[tokio::test] +async fn shlvl_warning_follows_the_commands_redirect() { + let mut bash = Bash::new(); + let r = bash + .exec("SHLVL=999 bash -c 'true' 2>/tmp/err; cat /tmp/err") + .await + .unwrap(); + assert_eq!( + r.stdout, + "bash: warning: shell level (1000) too high, resetting to 1\n" + ); + assert_eq!(r.stderr, ""); +} + +#[tokio::test] +async fn child_shell_stderr_honors_its_redirect_when_streaming() { + let mut bash = Bash::new(); + let streamed = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let sink = std::sync::Arc::clone(&streamed); + let r = bash + .exec_streaming( + "bash -c 'echo leak >&2; true' 2>/dev/null; echo next", + Box::new(move |_stdout, stderr| { + sink.lock().unwrap().push_str(&stderr.to_string()); + }), + ) + .await + .unwrap(); + assert_eq!(r.stdout, "next\n"); + assert_eq!(r.stderr, ""); + assert_eq!(streamed.lock().unwrap().as_str(), ""); +} diff --git a/crates/bashkit/tests/integration/spec_runner.rs b/crates/bashkit/tests/integration/spec_runner.rs index 98e6156f0..9758fc086 100644 --- a/crates/bashkit/tests/integration/spec_runner.rs +++ b/crates/bashkit/tests/integration/spec_runner.rs @@ -276,6 +276,9 @@ pub fn run_real_bash(script: &str) -> (String, i32) { .arg(&rewritten_script) .current_dir(sandbox.path()) .env("TMPDIR", sandbox.path()) + // A bashkit script is a top-level shell; drop the test runner's own + // SHLVL so real bash starts at 1 too. + .env_remove("SHLVL") .output() .expect("Failed to run bash"); diff --git a/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh b/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh new file mode 100644 index 000000000..29cae11ba --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/shlvl.test.sh @@ -0,0 +1,62 @@ +### shlvl_starts_at_one +# Cases end in `true` so real bash under the comparison harness's `bash -c` +# does not exec the last command in place (L-PROC-003). +echo "$SHLVL" +### expect +1 +### end + +### shlvl_child_shell_increments +bash -c 'echo "a=$SHLVL"; true' +bash -c 'bash -c "echo b=\$SHLVL; true"; true' +true +### expect +a=2 +b=3 +### end + +### shlvl_child_shell_increments_from_unset +unset SHLVL +bash -c 'echo "a=$SHLVL"; true' +bash -c 'bash -c "echo b=\$SHLVL; true"; true' +### expect +a=1 +b=2 +### end + +### shlvl_child_shell_increments_an_inherited_value +export SHLVL=3 +bash -c 'echo "c=$SHLVL"; true' +bash -c 'echo "d=${SHLVL}"; bash -c "echo e=\$SHLVL; true"; true' +true +### expect +c=4 +d=4 +e=5 +### end + +### shlvl_non_numeric_restarts_at_one +SHLVL=abc bash -c 'echo "f=$SHLVL"; true' +### expect +f=1 +### end + +### shlvl_too_high_resets_to_one +SHLVL=999 bash -c 'echo "g=$SHLVL"; true' +### expect +g=1 +### end + +### shlvl_negative_becomes_zero +SHLVL=-5 bash -c 'echo "h=$SHLVL"; true' +true +### expect +h=0 +### end + +### shlvl_is_exported_to_the_child +bash -c 'env | grep "^SHLVL=" ; true' +true +### expect +SHLVL=2 +### end diff --git a/knowledge/log.md b/knowledge/log.md index 6d189ba8a..669687b78 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -11,6 +11,8 @@ filesystem starts in `/home/user` by design, so they measure sandbox identity, not parity. Cases that score dash rather than bash (`sh -c` runs dash on Debian) are out of the corpus too. + identity, not parity. +* **Fix**: `bash`/`sh` child shells count `$SHLVL` like bash's `adjust_shell_level`: inherited level + 1 (non-numeric counts as 0, negative lands on 0, past 999 restarts at 1 with bash's warning), exported to grandchildren. A child's output no longer streams past the `bash` command's own output redirects (`bash -c 'echo x >&2' 2>/dev/null` printed `x`). bash's fork suppression, where a sole `bash -c` command execs without raising the level, is not modeled (L-PROC-003). The spec comparison runs real bash without the runner's `SHLVL`. * **Test**: Debian-oracle scoreboard. `just debian-oracle` / `.github/workflows/debian-oracle.yml` run the pseudo-linus bench (about 9,600 cases recorded on real Debian, fetched at a pinned commit because parts of its corpus derive from GPL suites) and fail when a tool drops below `scripts/debian-oracle/floors.tsv`. See operations/testing "Debian-Oracle Scoreboard". * **Fix**: `{arr[i]}` works as a redirect's fd variable in every position, `exec {A[1]}> file` and `exec {A[1]}>&-` alike. The name lexes as several literal parts because `[` opens a glob bracket, so the redirect parsed as a command called `{A[1]}` and failed with `command not found`. * **Fix**: `grep` follows GNU grep 3.11. Prefixes print as `FILE:LINE:BYTE:` (context lines with `-`), group separators appear between non-adjacent groups and files whenever a context option is given (`-NUM`, `--group-separator`, `--no-group-separator`), trailing context after `-m` is printed in full, `-o` skips empty matches and is POSIX leftmost-longest (`grep -oE 'ab|abcd'` prints `abcd`), `-b -o` gives each match's offset. New `grep_pattern` translates GNU BRE/ERE (brackets, intervals, `\< \> \b \w`, literal leading `*` in BRE, dropped leading ERE operator with GNU's warning, glibc error messages) onto awk's leftmost-longest `AwkRegex`; back-references work in BRE and ERE via fancy-regex (TM-DOS-025 cap). Options parse like getopt_long (unique prefixes such as `--no-gr`, `-T`, `-d`, `--label`, `-` for stdin, `-R` follows symlinks), an empty `-f` file matches nothing, a directory operand without `-r` reports `Is a directory`, invalid UTF-8 output turns the file binary, `\r` is line data. Debian-oracle grep score 60.5% to 96.8% (185 cases); grep's probes in the regex set all pass except two raw-`\x01` patterns the shell drops. Remaining misses are readdir order (L-GREP-003); L-GREP-002 lists the other gaps. See foundations/builtins "grep". diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 9d0713ddf..d26129610 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -44,7 +44,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` |----|------------|-----|----------| | L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command, so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell or a `bash -c` child does not take effect, because only the outermost command loop routes the exec fd table. `exec &>file` and `exec >&word` are ignored | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` | | L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` | -| L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | +| L-PROC-003 | No process spawning; external commands run as builtins. So bash's fork suppression has no equivalent: a `bash -c` that is the last command of its shell raises `$SHLVL` like any other child, where bash execs it in place and keeps the level (`bash -c 'bash -c "echo \$SHLVL"'` in a top-level script prints 3, bash prints 2) | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | | L-PROC-004 | `bash`/`sh` child shells nest at most 8 deep (and count against `max_function_depth`); deeper nesting fails `maximum nesting depth exceeded` | Child shells run in-process on one stack; unbounded nesting crashed the process (TM-DOS-125) | `l_proc_004_child_shell_depth` | | L-PIPE-001 | Pipeline stages stream only from the first stage that runs shell code (loop, group, function, `eval`). Leading single-builtin stages run to completion first and hand over their whole output, so they never get SIGPIPE (`sort big \| head -1` gives `PIPESTATUS` `0 0`, bash `141 0`); `yes`, `seq`, plain `cat`, `grep` and `tr` are the exception and stream. Downstream of a streaming stage, a single builtin reads all its input before it starts, except `head`, `read`, plain `cat`, `grep` (not `-c/-l/-L/-q`, context, `-b`) and `tr` (not `-s`): `while :; do echo; done \| sed p \| head -1` runs until a limit. Pipes hold 4 KiB, not 64 KiB, so a finite loop writing more than that into an early-exiting reader exits 141 where bash would exit 0. Pipelines nested more than 4 subshells deep run their stages in sequence | Builtins return their output as one value, not a stream; streaming them needs a reader/writer `Context`. The smaller pipe bounds how far a producer runs ahead (each byte costs budgeted commands); the nesting cap bounds native stack (TM-DOS-124) | `l_pipe_001_stages_run_sequentially` | | L-SUDO-001 | `sudo` runs its command as the one sandbox user: `-u`, `-g`, `-i` change nothing, `whoami` still prints `sandbox`, and there is never a password prompt. `busybox APPLET` runs bashkit's builtin of that name, not BusyBox's own applet (flags and messages are bashkit's) | The sandbox has no privilege boundary to cross; the VFS has no permission enforcement (L-FS-002) | `sudo-busybox.test.sh` |