Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand All @@ -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<String>) {
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<String> {
let exported_names: Vec<String> = self
.scoped
.var_attrs
Expand Down Expand Up @@ -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();
Expand All @@ -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
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions crates/bashkit/tests/integration/shlvl_tests.rs
Original file line number Diff line number Diff line change
@@ -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(), "");
}
3 changes: 3 additions & 0 deletions crates/bashkit/tests/integration/spec_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
62 changes: 62 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/shlvl.test.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
2 changes: 1 addition & 1 deletion knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading