From 237e0dcc7753f5d59f4a2ae80e73b2c318e7b572 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 19:40:37 +0000 Subject: [PATCH 1/5] fix(vars): $arr is element 0, PIPESTATUS for simple commands, platform vars, git init options - `$arr` / `$FUNCNAME` on an array expand to element 0 (was empty), including function-local arrays - simple commands set PIPESTATUS=(status) like bash - default PATH, SHELL, SHLVL, OSTYPE, HOSTTYPE, MACHTYPE (synthetic) - git init parses -q/--quiet and -b/--initial-branch instead of creating a repo in ./-q Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/builtins/git/client.rs | 19 ++++- crates/bashkit/src/builtins/git/cmd.rs | 81 +++++++++++++++++-- crates/bashkit/src/interpreter/mod.rs | 52 +++++++++++- .../integration/git_integration_tests.rs | 17 ++++ .../tests/integration/threat_model_tests.rs | 5 +- .../bash/shell-identity-vars.test.sh | 48 +++++++++++ 6 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/shell-identity-vars.test.sh diff --git a/crates/bashkit/src/builtins/git/client.rs b/crates/bashkit/src/builtins/git/client.rs index f505dfe7e..caae6152d 100644 --- a/crates/bashkit/src/builtins/git/client.rs +++ b/crates/bashkit/src/builtins/git/client.rs @@ -93,6 +93,16 @@ impl GitClient { /// The path must be within the virtual filesystem. Path traversal /// attacks are blocked by the VFS layer. pub async fn init(&self, fs: &Arc, repo_path: &Path) -> Result { + self.init_with_branch(fs, repo_path, "master").await + } + + /// `git init -b `: HEAD points at `refs/heads/`. + pub async fn init_with_branch( + &self, + fs: &Arc, + repo_path: &Path, + initial_branch: &str, + ) -> Result { let git_dir = vfs_join(repo_path, ".git"); // Check if already initialized @@ -110,9 +120,12 @@ impl GitClient { fs.mkdir(&vfs_join(&git_dir, "refs/heads"), true).await?; fs.mkdir(&vfs_join(&git_dir, "refs/tags"), true).await?; - // Create HEAD pointing to master - fs.write_file(&vfs_join(&git_dir, "HEAD"), b"ref: refs/heads/master\n") - .await?; + // Create HEAD pointing to the initial branch + fs.write_file( + &vfs_join(&git_dir, "HEAD"), + format!("ref: refs/heads/{initial_branch}\n").as_bytes(), + ) + .await?; // Create config with author info let config_content = format!( diff --git a/crates/bashkit/src/builtins/git/cmd.rs b/crates/bashkit/src/builtins/git/cmd.rs index 8a9747d22..091ef596b 100644 --- a/crates/bashkit/src/builtins/git/cmd.rs +++ b/crates/bashkit/src/builtins/git/cmd.rs @@ -130,17 +130,85 @@ async fn execute_git(ctx: Context<'_>, git_client: &super::GitClient) -> Result< } } +/// `git check-ref-format --branch` rules, enough to keep HEAD well formed. +#[cfg(feature = "git")] +fn valid_branch_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with(['-', '/', '.']) + && !name.ends_with(['/', '.']) + && !name.ends_with(".lock") + && !name.contains("..") + && !name.contains("@{") + && !name.contains("//") + && name != "@" + && !name + .chars() + .any(|c| c.is_control() || c.is_whitespace() || "~^:?*[\\".contains(c)) +} + #[cfg(feature = "git")] async fn git_init( ctx: Context<'_>, git_client: &super::GitClient, args: &[String], ) -> Result { - // Parse path argument (default to cwd) - let path = if args.is_empty() { - ctx.cwd.clone() - } else { - resolve_path(ctx.cwd, &args[0]) + let mut quiet = false; + let mut branch = "master".to_string(); + let mut dir: Option<&str> = None; + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + match arg { + "-q" | "--quiet" => quiet = true, + "-b" | "--initial-branch" => { + i += 1; + match args.get(i) { + Some(name) => branch = name.clone(), + None => { + return Ok(ExecResult::err( + format!("error: option `{arg}' requires a value\n"), + 129, + )); + } + } + } + _ if arg.starts_with("--initial-branch=") => { + branch = arg["--initial-branch=".len()..].to_string(); + } + "--" => { + dir = args.get(i + 1).map(String::as_str); + break; + } + _ if arg.starts_with('-') => { + return Ok(ExecResult::err( + format!("error: unknown option `{}'\n", arg.trim_start_matches('-')), + 129, + )); + } + _ => { + if dir.is_some() { + return Ok(ExecResult::err( + "usage: git init [-q | --quiet] [-b ] []\n" + .to_string(), + 129, + )); + } + dir = Some(arg); + } + } + i += 1; + } + if !valid_branch_name(&branch) { + return Ok(ExecResult::err( + format!("fatal: invalid initial branch name: '{branch}'\n"), + 128, + )); + } + + // Path argument defaults to cwd + let path = match dir { + Some(d) => resolve_path(ctx.cwd, d), + None => ctx.cwd.clone(), }; // Create directory if it doesn't exist @@ -148,7 +216,8 @@ async fn git_init( ctx.fs.mkdir(&path, true).await?; } - match git_client.init(&ctx.fs, &path).await { + match git_client.init_with_branch(&ctx.fs, &path, &branch).await { + Ok(_) if quiet => Ok(ExecResult::ok(String::new())), Ok(output) => Ok(ExecResult::ok(output)), Err(e) => git_err(e), } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 2420d5d6a..e35319e2b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -257,6 +257,8 @@ use fail::fail_point; /// The canonical /dev/null path. /// This is handled at the interpreter level to prevent custom filesystems from bypassing it. const DEV_NULL: &str = "/dev/null"; +/// Default `$PATH`, matching the Debian-style layout `uname` reports. +pub(crate) const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; /// Convert a [`SubCommand`](crate::builtins::SubCommand)'s command-scoped /// `(VAR, value)` pairs into AST [`Assignment`]s, so a plan's inner command @@ -1637,6 +1639,14 @@ impl Interpreter { variables.insert("EUID".to_string(), "1000".to_string()); variables.insert("PPID".to_string(), "0".to_string()); variables.insert("HOSTNAME".to_string(), hostname_val.clone()); + // Platform/shell identity agents branch on (`$OSTYPE`, `$PATH`, ...). + // Synthetic, never read from the host (TM-INF rules). + variables.insert("PATH".to_string(), DEFAULT_PATH.to_string()); + variables.insert("SHELL".to_string(), "/bin/bash".to_string()); + variables.insert("SHLVL".to_string(), "1".to_string()); + variables.insert("OSTYPE".to_string(), "linux-gnu".to_string()); + variables.insert("HOSTTYPE".to_string(), "x86_64".to_string()); + variables.insert("MACHTYPE".to_string(), "x86_64-pc-linux-gnu".to_string()); // BASH_VERSINFO array: (major minor patch build status machine) let mut arrays = HashMap::new(); @@ -1801,6 +1811,22 @@ impl Interpreter { } #[inline] + /// A simple command is a one-element pipeline: bash sets + /// `PIPESTATUS=(status)` after it. Skips the write when unchanged so the + /// copy-on-write array map is not cloned on every command. + fn set_simple_pipestatus(&mut self, code: i32) { + let unchanged = self.scoped.arrays.get("PIPESTATUS").is_some_and(|arr| { + arr.len() == 1 && arr.get(&0).is_some_and(|v| v.parse() == Ok(code)) + }); + self.pipestatus.clear(); + self.pipestatus.push(code); + if !unchanged { + let mut ps_arr = HashMap::with_capacity(1); + ps_arr.insert(0, code.to_string()); + self.arrays_mut().insert("PIPESTATUS".to_string(), ps_arr); + } + } + fn arrays_mut(&mut self) -> &mut HashMap> { Arc::make_mut(&mut self.scoped.arrays) } @@ -2959,7 +2985,11 @@ impl Interpreter { self.charge_command_execution()?; match command { - Command::Simple(simple) => self.execute_simple_command(simple, None).await, + Command::Simple(simple) => { + let result = self.execute_simple_command(simple, None).await?; + self.set_simple_pipestatus(result.exit_code); + Ok(result) + } Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await, Command::List(list) => self.execute_list(list).await, Command::Compound(compound, redirects) => { @@ -9783,6 +9813,18 @@ impl Interpreter { fn lookup_regular_variable(&self, name: &str) -> Option { for frame in self.call_stack.iter().rev() { if let Some(value) = frame.locals.get(name) { + // `local -a x=(...)` also leaves a scalar placeholder; the + // array it shadows-in is the real value. + if frame.local_arrays.contains_key(name) + && let Some(arr) = self.scoped.arrays.get(name) + { + return arr.get(&0).cloned(); + } + if frame.local_assoc_arrays.contains_key(name) + && let Some(arr) = self.scoped.assoc_arrays.get(name) + { + return arr.get("0").cloned(); + } return Some(value.clone()); } } @@ -9791,6 +9833,14 @@ impl Interpreter { return Some(value.clone()); } + // `$arr` on an array is `${arr[0]}` (indexed) or `${arr["0"]}` (assoc). + if let Some(arr) = self.scoped.arrays.get(name) { + return arr.get(&0).cloned(); + } + if let Some(arr) = self.scoped.assoc_arrays.get(name) { + return arr.get("0").cloned(); + } + self.env.get(name).cloned() } diff --git a/crates/bashkit/tests/integration/git_integration_tests.rs b/crates/bashkit/tests/integration/git_integration_tests.rs index fc4ed6ef8..6f57827cf 100644 --- a/crates/bashkit/tests/integration/git_integration_tests.rs +++ b/crates/bashkit/tests/integration/git_integration_tests.rs @@ -388,3 +388,20 @@ mod not_configured { assert!(result.stderr.contains("not configured")); } } + +#[tokio::test] +async fn git_init_options_are_parsed_not_taken_as_directory() { + let mut bash = create_git_bash(); + let r = bash + .exec("git init -q /repo; echo \"[$?]\"; ls /repo/.git/HEAD; git init -b main /r2 >/dev/null; cat /r2/.git/HEAD; git init --initial-branch=dev -q /r3; cat /r3/.git/HEAD; ls /home/user") + .await + .unwrap(); + assert_eq!( + r.stdout, + "[0]\n/repo/.git/HEAD\nref: refs/heads/main\nref: refs/heads/dev\n" + ); + let r = bash.exec("git init --bare /x").await.unwrap(); + assert_eq!(r.exit_code, 129); + let r = bash.exec("git init -b 'a b' /y").await.unwrap(); + assert_eq!(r.exit_code, 128); +} diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index db693457c..1f625b89c 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -602,8 +602,11 @@ mod information_disclosure { let result = bash.exec("echo $ALLOWED_VAR").await.unwrap(); assert_eq!(result.stdout.trim(), "allowed_value"); - // But other vars aren't magically available + // But host vars aren't magically available: $PATH is the synthetic + // sandbox default, never the host's. let result = bash.exec("echo $PATH").await.unwrap(); + assert_eq!(result.stdout.trim(), "/usr/local/bin:/usr/bin:/bin"); + let result = bash.exec("echo $HOST_ONLY_SENTINEL_VAR").await.unwrap(); assert!(result.stdout.trim().is_empty()); } diff --git a/crates/bashkit/tests/spec_cases/bash/shell-identity-vars.test.sh b/crates/bashkit/tests/spec_cases/bash/shell-identity-vars.test.sh new file mode 100644 index 000000000..66dabd501 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/shell-identity-vars.test.sh @@ -0,0 +1,48 @@ +# `$name` on an array is element 0; PIPESTATUS after simple commands; +# platform identity variables scripts branch on. + +### array_scalar_access_is_element_zero +a=(x y z) +echo "[$a]" +declare -A m=([0]=zero [k]=v) +echo "[$m]" +f() { local -a b=(p q); echo "[$b]"; } +f +### expect +[x] +[zero] +[p] +### end + +### funcname_scalar +outer() { echo "[$FUNCNAME]"; inner; } +inner() { echo "[$FUNCNAME][${FUNCNAME[*]}]"; } +outer +### expect +[outer] +[inner][inner outer] +### end + +### pipestatus_after_simple_command +true +echo "[${PIPESTATUS[@]}]" +false +echo "[${PIPESTATUS[@]}]" +true | false | true +echo "[${PIPESTATUS[@]}]" +### expect +[0] +[1] +[0 1 0] +### end + +### ostype_and_friends_set +case "$OSTYPE" in linux*) echo linux;; *) echo "other:$OSTYPE";; esac +[ -n "$MACHTYPE" ] && [ -n "$HOSTTYPE" ] && echo machine +[ -n "$PATH" ] && echo path +### bash_diff: host bash may run on a non-Linux CI image +### expect +linux +machine +path +### end From e7cc730160f167090a5c4cc4fba43cee849302c9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 19:53:53 +0000 Subject: [PATCH 2/5] feat(builtins): extglob in pathname expansion, printf %(fmt)T !(pat), @(a|b), +(x) now expand against files like bash with extglob on, even when the word has no other glob chars. printf supports bash's %(fmt)T time directive, sharing date's virtual clock and TZ policy. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/builtins/date.rs | 22 +++ crates/bashkit/src/builtins/printf.rs | 171 +++++++++++++++++- crates/bashkit/src/interpreter/glob.rs | 4 +- crates/bashkit/src/interpreter/mod.rs | 24 +-- .../tests/integration/threat_model_tests.rs | 13 ++ .../tests/spec_cases/bash/extglob.test.sh | 15 ++ .../tests/spec_cases/bash/printf.test.sh | 27 +++ knowledge/operations/limitations.md | 1 + knowledge/security/threat-model.md | 2 +- 9 files changed, 262 insertions(+), 17 deletions(-) diff --git a/crates/bashkit/src/builtins/date.rs b/crates/bashkit/src/builtins/date.rs index f5a9cd091..990f9af62 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -52,6 +52,7 @@ use crate::interpreter::ExecResult; /// the real clock so callers can blind absolute wall-clock time without /// breaking elapsed-time logic. The two modes are mutually exclusive /// — `fixed_epoch` wins if both are set. +#[derive(Clone, Copy, Default)] pub struct Date { /// Fixed UTC epoch for virtualized time. None = use real system clock. fixed_epoch: Option>, @@ -172,6 +173,27 @@ impl Date { } } + /// Format epoch `seconds` (or now, for `None`) with strftime `format` + /// in the sandbox `TZ`. Shared with printf's `%(fmt)T` so both builtins + /// see one virtual clock and one timezone policy. + pub(super) fn strftime( + &self, + tz: Option<&String>, + seconds: Option, + format: &str, + ) -> std::result::Result { + let timezone = + SandboxTimezone::from_env(tz).map_err(|e| e.replacen("date:", "printf:", 1))?; + let dt = match seconds { + Some(s) => DateTime::from_timestamp(s, 0) + .ok_or_else(|| format!("printf: time out of range: {s}"))?, + None => self.now(), + }; + let format = translate_gnu_format(format); + validate_format(&format).map_err(|e| format!("printf: {e}"))?; + Ok(timezone.format(&dt, &format)) + } + fn now(&self) -> DateTime { if let Some(t) = self.fixed_epoch { return t; diff --git a/crates/bashkit/src/builtins/printf.rs b/crates/bashkit/src/builtins/printf.rs index 7b8a08998..e1fd6b099 100644 --- a/crates/bashkit/src/builtins/printf.rs +++ b/crates/bashkit/src/builtins/printf.rs @@ -14,12 +14,24 @@ use super::generated::format::{ FormatArgument, FormatArguments, FormatError, FormatItem, parse_spec_and_escape, }; use super::limits::PRINTF_MAX_DIAG_CHARS as MAX_PRINTF_DIAG_CHARS; -use super::{Builtin, Context, MAX_FORMAT_WIDTH}; +use super::{Builtin, Context, Date, MAX_FORMAT_WIDTH}; use crate::error::Result; use crate::interpreter::{ExecResult, is_internal_variable}; /// printf builtin - formatted string output -pub struct Printf; +/// +/// Holds the sandbox clock so `%(fmt)T` reads the same virtual time as `date`. +#[derive(Default)] +pub struct Printf { + clock: Date, +} + +impl Printf { + /// printf whose `%(fmt)T` uses `clock` (fixed epoch / offset aware). + pub fn with_clock(clock: Date) -> Self { + Self { clock } + } +} #[async_trait] impl Builtin for Printf { @@ -51,6 +63,12 @@ impl Builtin for Printf { }; let args: Vec = args_iter.cloned().collect(); + let (format, args) = match expand_time_directives(&format, args, |seconds, fmt| { + self.clock.strftime(ctx.env.get("TZ"), seconds, fmt) + }) { + Ok(v) => v, + Err(err) => return Ok(ExecResult::err(format!("{err}\n"), 1)), + }; let output = match render_printf(&format, &args) { Ok(output) => output, Err(err) => return Ok(ExecResult::err(err, 1)), @@ -96,6 +114,118 @@ fn render_printf(format: &str, args: &[String]) -> std::result::Result slot i % slots, as the format repeats) is replaced by +/// the formatted time. Argument `""` or `-1` means now. `-2` (bash: shell +/// start time) also means now; the shell start instant isn't tracked. +/// Missing arguments for a time slot also mean now. +fn expand_time_directives( + format: &str, + mut args: Vec, + mut strftime: impl FnMut(Option, &str) -> std::result::Result, +) -> std::result::Result<(String, Vec), String> { + if !format.contains(")T") { + return Ok((format.to_string(), args)); + } + let bytes = format.as_bytes(); + let mut out = String::with_capacity(format.len()); + let mut slots = Vec::new(); + let mut i = 0; + let mut copied = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'%' if bytes.get(i + 1) == Some(&b'%') => i += 2, + b'%' => { + i += 1; + while i < bytes.len() && b"-+ #0'".contains(&bytes[i]) { + i += 1; + } + for part in 0..2 { + if part == 1 { + if bytes.get(i) != Some(&b'.') { + break; + } + i += 1; + } + if bytes.get(i) == Some(&b'*') { + slots.push(Slot::Plain); + i += 1; + } else { + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + } + if bytes.get(i) == Some(&b'(') + && let Some(close) = format[i..].find(")T") + { + let fmt = &format[i + 1..i + close]; + out.push_str(&format[copied..i]); + out.push('s'); + slots.push(Slot::Time(if fmt.is_empty() { + "%X".to_string() + } else { + fmt.to_string() + })); + i += close + 2; + copied = i; + } else if i < bytes.len() { + slots.push(Slot::Plain); + i += 1; + } + } + _ => i += 1, + } + } + out.push_str(&format[copied..]); + if !slots.iter().any(|s| matches!(s, Slot::Time(_))) { + return Ok((format.to_string(), args)); + } + + // Pad the last pass up to its final time slot so a missing time + // argument still prints "now" (bash treats it as -1). + let n = slots.len(); + let last_time = slots + .iter() + .rposition(|s| matches!(s, Slot::Time(_))) + .unwrap_or(0); + let pass_start = if args.is_empty() { + 0 + } else { + (args.len() - 1) / n * n + }; + let filled = args.len() - pass_start; + if args.is_empty() || filled < n { + let want = pass_start + last_time + 1; + while args.len() < want { + args.push(String::new()); + } + } + + for (idx, arg) in args.iter_mut().enumerate() { + if let Slot::Time(fmt) = &slots[idx % n] { + let seconds = match arg.trim() { + "" | "-1" | "-2" => None, + s => Some(parse_leading_i64(s)), + }; + *arg = strftime(seconds, fmt)?; + } + } + Ok((out, args)) +} + fn bytes_to_stdout_string(bytes: Vec) -> String { String::from_utf8_lossy(&bytes).into_owned() } @@ -518,6 +648,43 @@ mod tests { use super::*; use crate::interpreter::ExecResult; + fn fake_time(seconds: Option, fmt: &str) -> std::result::Result { + Ok(format!( + "<{fmt}@{}>", + seconds.map_or("now".into(), |s| s.to_string()) + )) + } + + #[test] + fn time_directive_rewritten_to_string_slot() { + let (f, a) = + expand_time_directives("%-5(%H)T|%s\\n", vec!["7".into(), "x".into()], fake_time) + .unwrap(); + assert_eq!(f, "%-5s|%s\\n"); + assert_eq!(a, vec!["<%H@7>".to_string(), "x".to_string()]); + } + + #[test] + fn time_directive_maps_args_across_passes_and_pads_missing() { + let (_, a) = expand_time_directives( + "%s %(%F)T", + vec!["a".into(), "1".into(), "b".into()], + fake_time, + ) + .unwrap(); + assert_eq!(a, vec!["a", "<%F@1>", "b", "<%F@now>"]); + let (_, a) = expand_time_directives("%()T", vec![], fake_time).unwrap(); + assert_eq!(a, vec!["<%X@now>"]); + } + + #[test] + fn time_directive_skips_literal_percent_and_plain_formats() { + let (f, a) = expand_time_directives("%% %s", vec!["x".into()], fake_time).unwrap(); + assert_eq!((f.as_str(), a), ("%% %s", vec!["x".to_string()])); + let err = expand_time_directives("%(%F)T", vec![], |_, _| Err("bad".into())); + assert_eq!(err.unwrap_err(), "bad"); + } + #[test] fn generated_formatter_repeats_format_until_args_exhausted() { assert_eq!( diff --git a/crates/bashkit/src/interpreter/glob.rs b/crates/bashkit/src/interpreter/glob.rs index 62ba970ee..fc467c260 100644 --- a/crates/bashkit/src/interpreter/glob.rs +++ b/crates/bashkit/src/interpreter/glob.rs @@ -707,7 +707,7 @@ impl Interpreter { &self, item: &str, ) -> std::result::Result, String> { - if !self.contains_glob_chars(item) || self.is_noglob() { + if !(self.contains_glob_chars(item) || self.contains_extglob(item)) || self.is_noglob() { return Ok(vec![item.to_string()]); } let glob_matches = self.expand_glob(item).await.unwrap_or_default(); @@ -827,7 +827,7 @@ impl Interpreter { let is_last = idx + 1 == components.len(); let mut next: Vec<(PathBuf, String)> = Vec::new(); - if self.contains_glob_chars(component) { + if self.contains_glob_chars(component) || self.contains_extglob(component) { // Dotfiles are hidden per component unless dotglob is set or this // component explicitly starts with '.'. let component_starts_with_dot = component.starts_with('.'); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index e35319e2b..d4d139d1b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1414,7 +1414,6 @@ impl Interpreter { "return" => Return, "test" => Test, "[" => Bracket, - "printf" => Printf, "export" => Export, "read" => Read, "set" => Set, @@ -1575,18 +1574,19 @@ impl Interpreter { // THREAT[TM-INF-018]: Resolve the virtual clock mode for `date`. // Priority: fixed_epoch > epoch_offset > real clock. + // printf's `%(fmt)T` shares the same clock. + let clock = if let Some(epoch) = fixed_epoch { + use chrono::DateTime; + builtins::Date::with_fixed_epoch(DateTime::from_timestamp(epoch, 0).unwrap_or_default()) + } else if let Some(offset) = epoch_offset { + builtins::Date::with_offset_seconds(offset) + } else { + builtins::Date::new() + }; + builtins.insert("date".to_string(), Arc::new(clock)); builtins.insert( - "date".to_string(), - Arc::new(if let Some(epoch) = fixed_epoch { - use chrono::DateTime; - builtins::Date::with_fixed_epoch( - DateTime::from_timestamp(epoch, 0).unwrap_or_default(), - ) - } else if let Some(offset) = epoch_offset { - builtins::Date::with_offset_seconds(offset) - } else { - builtins::Date::new() - }), + "printf".to_string(), + Arc::new(builtins::Printf::with_clock(clock)), ); // System info builtins (configurable virtual values) diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index 1f625b89c..c74da7086 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -4181,6 +4181,19 @@ mod tm_inf_018_date { assert_eq!(r.stdout.trim(), "1700000000"); } + /// TM-INF-018: printf `%(fmt)T` reads the same virtual clock as `date`, + /// so it can't bypass `fixed_epoch`, and it ignores host timezone state. + #[tokio::test] + async fn fixed_epoch_freezes_printf_time_directive() { + let mut bash = Bash::builder().fixed_epoch(1_700_000_000).build(); + let r = bash + .exec("printf '%(%s %z)T|%(%s)T\\n' -1 ''") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "1700000000 +0000|1700000000\n"); + } + /// TM-INF-018: `Bash::builder().epoch_offset(N)` keeps the clock /// ticking but shifts its absolute value by N seconds. Verify two /// consecutive reads differ by less than 1s (ticking) yet sit at diff --git a/crates/bashkit/tests/spec_cases/bash/extglob.test.sh b/crates/bashkit/tests/spec_cases/bash/extglob.test.sh index a279298fc..15b24fde3 100644 --- a/crates/bashkit/tests/spec_cases/bash/extglob.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/extglob.test.sh @@ -125,3 +125,18 @@ v="é" ### expect no ### end + +### extglob_pathname_negation +# !(pat) as a filename glob (no other glob chars in the word) +shopt -s extglob +mkdir -p /tmp/xg && cd /tmp/xg && touch a.c b.c a.h n.txt +echo !(*.c) +echo @(a|n).* +echo +(a).c +echo *.@(c|h) +### expect +a.h n.txt +a.c a.h n.txt +a.c +a.c a.h b.c +### end diff --git a/crates/bashkit/tests/spec_cases/bash/printf.test.sh b/crates/bashkit/tests/spec_cases/bash/printf.test.sh index ca375a88a..17b266e34 100644 --- a/crates/bashkit/tests/spec_cases/bash/printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/printf.test.sh @@ -248,3 +248,30 @@ printf '%q\n' "it's" ### expect it\'s ### end + +### printf_time_directive_epoch +# %(fmt)T formats epoch seconds +TZ=UTC printf '%(%Y-%m-%d %H:%M:%S)T\n' 86400 +### expect +1970-01-02 00:00:00 +### end + +### printf_time_directive_width_and_repeat +TZ=UTC printf '[%-8(%H:%M)T] %s\n' 3600 a 7200 b +### expect +[01:00 ] a +[02:00 ] b +### end + +### printf_time_directive_missing_arg_is_now +# A missing argument means now; only check the shape +printf '%(%Y)T\n' | grep -cE '^[0-9]{4}$' +### expect +1 +### end + +### printf_time_directive_v +TZ=UTC printf -v d '%(%F)T' 0; echo "$d" +### expect +1970-01-01 +### end diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index a00d260c9..68d116736 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -137,6 +137,7 @@ pass in CI); only divergences and boundaries are recorded here. | L-SED-006 | sed | Scripts that carry the stream past a range's end with `n`, `N` or `D` can still diverge from GNU's range bookkeeping in rare shapes (e.g. `sed '$!D;1,2!x'`). A randomized differential sweep of 18,000 generated scripts against GNU sed 4.9 finds 3 such cases; all combine a multi-line pattern space with a range address, a shape absent from ordinary scripts | `ranges_that_the_stream_skipped_past` | | L-CURL-001 | curl | Spec-test coverage for methods/headers/auth/redirects not ported (needs `http_client` + allowlist in harness); payload behavior has integration and real-curl differential coverage | stance | | L-CURL-002 | curl/wget | Unknown options are ignored for compatibility, not rejected (real curl/wget error); deliberate leniency | `curl.rs` | +| L-PRINTF-001 | printf | `%(fmt)T` argument `-2` (bash: shell start time) formats the current time; the shell start instant is not tracked. GNU-only `%N` in the time format is expanded to nanoseconds, bash prints it literally | `printf.rs::expand_time_directives` | | L-STR-001 | strings | Accepts dash-prefixed filenames (e.g. `-data.bin`), so only a lone unknown short option (`-Q`) is rejected as invalid; GNU rejects `-data.bin` too | `strings.rs` | Safety boundaries (enforced, not bugs): printf width/precision caps, diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index b9766713c..18f87bdd7 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1318,7 +1318,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track | ~~TM-INJ-015~~ | ~~`export` bypasses `is_internal_variable()`~~ | ~~Internal prefix injection via export~~ | ~~Add `is_internal_variable()` check~~ (**FIXED**) | | ~~TM-INJ-016~~ | ~~`_ARRAY_READ_` prefix not in `is_internal_variable()`~~ | ~~Arbitrary array creation/overwrite via marker injection~~ | ~~Add `_ARRAY_READ_` prefix to `is_internal_variable()`~~ (**FIXED**) | | TM-INF-017 | `set` and `declare -p` leak internal markers | Internal state disclosure (_NAMEREF_, _READONLY_, _UPPER_, _LOWER_) | Filter `is_internal_variable()` names from output | -| TM-INF-018 | `date` exposes host clock or timezone | Timezone fingerprinting, timing correlation | `date` consults only virtual `ctx.env["TZ"]`: unset, empty, invalid, POSIX-rule, and path-style values resolve to UTC; valid static IANA zones select parsing/display rules, and `-u` displays UTC. Timezone-naive `touch -t` stamps are UTC so `date -r` cannot reveal host-local conversion. `fixed_epoch(N)` freezes the instant; `epoch_offset(N)` shifts the real clock (mutually exclusive, last call wins). Without the `tzdata` feature there is no IANA database, so `date` reports that it cannot honour a named zone and exits 1 rather than silently formatting in UTC; the diagnostic omits the `TZ` value, which may itself be path-shaped. The fail-closed direction is unchanged in both builds: host timezone state is never consulted. Regression/security coverage: `date_timezone_tests`, `date_timezone_no_tzdata_tests`, GNU-date differentials, `fileops.test::touch_t_sets_file_mtime`, and `tm_inf_018_date`. | **MITIGATED** | +| TM-INF-018 | `date` exposes host clock or timezone | Timezone fingerprinting, timing correlation | `date` consults only virtual `ctx.env["TZ"]`: unset, empty, invalid, POSIX-rule, and path-style values resolve to UTC; valid static IANA zones select parsing/display rules, and `-u` displays UTC. Timezone-naive `touch -t` stamps are UTC so `date -r` cannot reveal host-local conversion. printf `%(fmt)T` shares `date`'s clock and `TZ` policy (`Date::strftime`), so it cannot bypass either. `fixed_epoch(N)` freezes the instant; `epoch_offset(N)` shifts the real clock (mutually exclusive, last call wins). Without the `tzdata` feature there is no IANA database, so `date` reports that it cannot honour a named zone and exits 1 rather than silently formatting in UTC; the diagnostic omits the `TZ` value, which may itself be path-shaped. The fail-closed direction is unchanged in both builds: host timezone state is never consulted. Regression/security coverage: `date_timezone_tests`, `date_timezone_no_tzdata_tests`, GNU-date differentials, `fileops.test::touch_t_sets_file_mtime`, and `tm_inf_018_date`. | **MITIGATED** | | ~~TM-DOS-041~~ | ~~Brace expansion `{N..M}` unbounded range~~ | ~~OOM via `{1..999999999}`~~ | `MAX_STATIC_BRACE_RANGE = 100_000` parser-time check (`parser/budget.rs`, `BraceRangeTooLarge`); runtime fallback `MAX_BRACE_RANGE = 10_000` in `try_expand_range` treats oversized ranges as literals (**FIXED**) | | ~~TM-DOS-042~~ | ~~Brace expansion combinatorial explosion~~ | ~~OOM/stack overflow via `{1..100}{1..100}{1..100}` (1M strings) or `{a,b}{a,b}...` (deep recursion)~~ | `expand_braces` caps total emitted strings (`MAX_BRACE_EXPANSION_TOTAL = 100_000`). The count cap alone was insufficient for comma-lists: it is only charged on recursion *return*, so the first DFS path descended one frame per group with the count still zero and stack-overflowed. Now also bounds recursion depth and cumulative output bytes (`MAX_EXPANSION_RESULT_BYTES`) so it degrades to a bounded literal result (**FIXED**) | | ~~TM-DOS-043~~ | ~~Arithmetic overflow in side-effect evaluation~~ | ~~Panic (DoS) in debug mode via `((x+=1))` or boundary `++`/`--`~~ | ~~Use wrapping arithmetic for every side-effect operation~~ (**FIXED**) | From 3e6c0e2c0410b236524d9273a7250100b3834951 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 20:00:37 +0000 Subject: [PATCH 3/5] fix(builtins): /dev/stdin, /dev/stdout, /dev/stderr as file operands cat /dev/stdin, grep x /dev/fd/0 and tee /dev/stderr opened these paths in the VFS, where they don't exist. When an argument names one, the builtin's VFS view now serves stdin for fd 0 and captures writes to fd 1/2 into the builtin's stdout/stderr. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/fs/mod.rs | 2 + crates/bashkit/src/fs/std_streams.rs | 211 ++++++++++++++++++ crates/bashkit/src/interpreter/mod.rs | 26 ++- .../tests/integration/dev_fd_path_tests.rs | 49 ++++ .../spec_cases/bash/dev-fd-paths.test.sh | 14 ++ knowledge/foundations/vfs.md | 12 +- 6 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 crates/bashkit/src/fs/std_streams.rs diff --git a/crates/bashkit/src/fs/mod.rs b/crates/bashkit/src/fs/mod.rs index 8d23745e6..9187e32d2 100644 --- a/crates/bashkit/src/fs/mod.rs +++ b/crates/bashkit/src/fs/mod.rs @@ -411,6 +411,7 @@ mod readonly; #[cfg(feature = "realfs")] mod realfs; mod search; +mod std_streams; mod traits; pub use backend::FsBackend; @@ -427,6 +428,7 @@ pub use search::{ SearchCapabilities, SearchCapable, SearchMatch, SearchProvider, SearchQuery, SearchResults, }; #[allow(unused_imports)] +pub(crate) use std_streams::{StdStreamsFs, args_name_std_stream}; pub use traits::{DirEntry, FileSystem, FileSystemExt, FileType, Metadata, fs_errors}; use crate::error::Result; diff --git a/crates/bashkit/src/fs/std_streams.rs b/crates/bashkit/src/fs/std_streams.rs new file mode 100644 index 000000000..9ba46f43c --- /dev/null +++ b/crates/bashkit/src/fs/std_streams.rs @@ -0,0 +1,211 @@ +//! `/dev/stdin`, `/dev/stdout`, `/dev/stderr` as builtin file operands. +//! +//! Decision: redirects already route these paths to descriptors (see +//! `dev_fd_alias` in the interpreter). Builtins that open a path themselves +//! (`cat /dev/stdin`, `tee /dev/stderr`, `grep x /dev/fd/0`) go through the +//! VFS instead, where these paths don't exist. The interpreter wraps the +//! builtin's VFS view in [`StdStreamsFs`] only when an argument names one of +//! these paths: reads of fd 0 return the command's stdin, writes to fd 1/2 +//! are captured and appended to the builtin's stdout/stderr afterwards. +//! Only fds 0-2: `/dev/fd/63` etc. stay real VFS files (process substitution). + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use super::{DirEntry, FileSystem, FileSystemExt, FileType, FsLimits, FsUsage, Metadata}; +use crate::error::Result; +use crate::time_compat::{SystemTime, UNIX_EPOCH}; + +/// Captured writes to `/dev/stdout` and `/dev/stderr`. +#[derive(Default)] +pub(crate) struct StdCapture { + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, +} + +pub(crate) struct StdStreamsFs { + inner: Arc, + stdin: Vec, + capture: Arc>, +} + +/// Which standard stream a path names, if any. +pub(crate) fn std_stream_fd(path: &Path) -> Option { + match super::normalize_path(path).to_str()? { + "/dev/stdin" | "/dev/fd/0" => Some(0), + "/dev/stdout" | "/dev/fd/1" => Some(1), + "/dev/stderr" | "/dev/fd/2" => Some(2), + _ => None, + } +} + +/// Cheap pre-check on raw arguments, so most builtin calls skip the wrapper. +pub(crate) fn args_name_std_stream(args: &[String]) -> bool { + args.iter() + .any(|a| a.contains("/dev/std") || a.contains("/dev/fd/")) +} + +impl StdStreamsFs { + pub(crate) fn wrap( + inner: Arc, + stdin: &[u8], + ) -> (Arc, Arc>) { + let capture = Arc::new(Mutex::new(StdCapture::default())); + let fs = Arc::new(Self { + inner, + stdin: stdin.to_vec(), + capture: Arc::clone(&capture), + }); + (fs, capture) + } + + fn write_stream(&self, fd: u8, content: &[u8]) { + let mut capture = self.capture.lock().unwrap_or_else(|e| e.into_inner()); + match fd { + 1 => capture.stdout.extend_from_slice(content), + _ => capture.stderr.extend_from_slice(content), + } + } + + fn stream_metadata(&self) -> Metadata { + Metadata { + file_type: FileType::Fifo, + size: 0, + mode: 0o620, + modified: UNIX_EPOCH, + created: UNIX_EPOCH, + } + } +} + +#[async_trait] +impl FileSystemExt for StdStreamsFs { + fn usage(&self) -> FsUsage { + self.inner.usage() + } + async fn mkfifo(&self, path: &Path, mode: u32) -> Result<()> { + self.inner.mkfifo(path, mode).await + } + fn limits(&self) -> FsLimits { + self.inner.limits() + } + fn backend_kind(&self) -> &'static str { + self.inner.backend_kind() + } +} + +#[async_trait] +impl FileSystem for StdStreamsFs { + async fn read_file(&self, path: &Path) -> Result> { + match std_stream_fd(path) { + Some(0) => Ok(self.stdin.clone()), + _ => self.inner.read_file(path).await, + } + } + async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> { + match std_stream_fd(path) { + Some(fd @ (1 | 2)) => { + self.write_stream(fd, content); + Ok(()) + } + _ => self.inner.write_file(path, content).await, + } + } + async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> { + match std_stream_fd(path) { + Some(fd @ (1 | 2)) => { + self.write_stream(fd, content); + Ok(()) + } + _ => self.inner.append_file(path, content).await, + } + } + async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> { + self.inner.mkdir(path, recursive).await + } + async fn remove(&self, path: &Path, recursive: bool) -> Result<()> { + self.inner.remove(path, recursive).await + } + async fn stat(&self, path: &Path) -> Result { + match std_stream_fd(path) { + Some(_) => Ok(self.stream_metadata()), + None => self.inner.stat(path).await, + } + } + async fn read_dir(&self, path: &Path) -> Result> { + self.inner.read_dir(path).await + } + async fn exists(&self, path: &Path) -> Result { + match std_stream_fd(path) { + Some(_) => Ok(true), + None => self.inner.exists(path).await, + } + } + async fn rename(&self, from: &Path, to: &Path) -> Result<()> { + self.inner.rename(from, to).await + } + async fn copy(&self, from: &Path, to: &Path) -> Result<()> { + match (std_stream_fd(from), std_stream_fd(to)) { + (None, None) => self.inner.copy(from, to).await, + _ => { + let data = self.read_file(from).await?; + self.write_file(to, &data).await + } + } + } + async fn symlink(&self, target: &Path, link: &Path) -> Result<()> { + self.inner.symlink(target, link).await + } + async fn read_link(&self, path: &Path) -> Result { + self.inner.read_link(path).await + } + async fn chmod(&self, path: &Path, mode: u32) -> Result<()> { + self.inner.chmod(path, mode).await + } + async fn set_modified_time(&self, path: &Path, time: SystemTime) -> Result<()> { + self.inner.set_modified_time(path, time).await + } + fn as_search_capable(&self) -> Option<&dyn super::SearchCapable> { + // Indexed search would bypass the stdin override. + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs::InMemoryFs; + + #[tokio::test] + async fn reads_stdin_and_captures_writes() { + let inner: Arc = Arc::new(InMemoryFs::new()); + let (fs, cap) = StdStreamsFs::wrap(Arc::clone(&inner), b"in\n"); + assert_eq!( + fs.read_file(Path::new("/dev/stdin")).await.unwrap(), + b"in\n" + ); + assert_eq!(fs.read_file(Path::new("/dev/fd/0")).await.unwrap(), b"in\n"); + fs.write_file(Path::new("/dev/stdout"), b"o").await.unwrap(); + fs.append_file(Path::new("/dev/fd/2"), b"e").await.unwrap(); + assert!(fs.exists(Path::new("/dev/stderr")).await.unwrap()); + let (out, err) = { + let cap = cap.lock().unwrap(); + (cap.stdout.clone(), cap.stderr.clone()) + }; + assert_eq!((out.as_slice(), err.as_slice()), (&b"o"[..], &b"e"[..])); + // Nothing reached the real VFS. + assert!(!inner.exists(Path::new("/dev/stdout")).await.unwrap()); + } + + #[tokio::test] + async fn other_fds_pass_through() { + let inner: Arc = Arc::new(InMemoryFs::new()); + let (fs, _) = StdStreamsFs::wrap(Arc::clone(&inner), b""); + assert_eq!(std_stream_fd(Path::new("/dev/fd/63")), None); + assert_eq!(std_stream_fd(Path::new("/dev/../dev/stdin")), Some(0)); + fs.write_file(Path::new("/tmp/x"), b"1").await.unwrap(); + assert_eq!(inner.read_file(Path::new("/tmp/x")).await.unwrap(), b"1"); + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index d4d139d1b..f2c52f0fb 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -6543,6 +6543,16 @@ impl Interpreter { let execution_extensions = self.current_execution_extensions(); let fs = self.builtin_file_system(access, &execution_extensions); + // /dev/stdin, /dev/stdout, /dev/stderr as file operands. + let (fs, std_capture) = if crate::fs::args_name_std_stream(args) { + let (fs, capture) = crate::fs::StdStreamsFs::wrap( + fs, + stdin.map_or(&[][..], crate::StreamData::as_bytes), + ); + (fs, Some(capture)) + } else { + (fs, None) + }; let shell_ref = ShellRef { builtins: &self.builtins, host_builtins: self.host_builtins.as_ref(), @@ -6577,13 +6587,27 @@ impl Interpreter { // THREAT[TM-INT-001]: Execute builtin with panic catching for security let result = AssertUnwindSafe(builtin.execute(ctx)).catch_unwind().await; - let result = match result { + let mut result = match result { Ok(Ok(exec_result)) => exec_result, Ok(Err(e)) => return Err(e), Err(_panic) => { ExecResult::err(format!("bash: {}: builtin failed unexpectedly\n", name), 1) } }; + if let Some(capture) = std_capture { + let capture = + std::mem::take(&mut *capture.lock().unwrap_or_else(|e| e.into_inner())); + if !capture.stdout.is_empty() { + result + .stdout + .append(&crate::StreamData::from(capture.stdout)); + } + if !capture.stderr.is_empty() { + result + .stderr + .append(&crate::StreamData::from(capture.stderr)); + } + } self.execution_budget.consume_work( u64::try_from( result diff --git a/crates/bashkit/tests/integration/dev_fd_path_tests.rs b/crates/bashkit/tests/integration/dev_fd_path_tests.rs index 831275de1..6d94d21f4 100644 --- a/crates/bashkit/tests/integration/dev_fd_path_tests.rs +++ b/crates/bashkit/tests/integration/dev_fd_path_tests.rs @@ -105,3 +105,52 @@ async fn dot_dot_spelling_is_normalized() { assert_eq!(r.stderr, "err\n"); assert!(!bash.fs().exists(Path::new("/dev/stderr")).await.unwrap()); } + +// Builtin file operands: the builtin opens the path itself. + +#[tokio::test] +async fn cat_dev_stdin_operand_reads_stdin() { + let (_, r) = run("echo hi | cat /dev/stdin; echo x | cat /dev/fd/0").await; + assert_eq!(r.stdout, "hi\nx\n"); + assert_eq!(r.stderr, ""); +} + +#[tokio::test] +async fn tee_dev_stderr_operand_copies_to_stderr() { + let (bash, r) = run("echo t | tee /dev/stderr").await; + assert_eq!(r.stdout, "t\n"); + assert_eq!(r.stderr, "t\n"); + assert!(!bash.fs().exists(Path::new("/dev/stderr")).await.unwrap()); +} + +#[tokio::test] +async fn tee_dev_stderr_operand_follows_redirects() { + let (_, r) = + run("echo t | tee /dev/stderr 2>/dev/null; echo u | tee /dev/stderr 2>&1 >/dev/null").await; + assert_eq!(r.stdout, "t\nu\n"); + assert_eq!(r.stderr, ""); +} + +#[tokio::test] +async fn text_tools_read_dev_stdin_operand() { + let (_, r) = run("echo w | grep w /dev/stdin; echo json | sed s/j/J/ /dev/stdin; echo q | head -1 /dev/stdin").await; + assert_eq!(r.stdout, "w\nJson\nq\n"); +} + +#[tokio::test] +async fn dev_stdin_exists_for_test_builtin() { + let (_, r) = run("[ -e /dev/stdin ] && echo yes").await; + assert_eq!(r.stdout, "yes\n"); +} + +#[tokio::test] +async fn process_substitution_fds_still_work() { + let (_, r) = run("diff <(echo 1) <(echo 1) && echo same; cat <(echo p)").await; + assert_eq!(r.stdout, "same\np\n"); +} + +#[tokio::test] +async fn literal_dev_stdin_argument_is_untouched() { + let (_, r) = run("echo /dev/stdin").await; + assert_eq!(r.stdout, "/dev/stdin\n"); +} diff --git a/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh b/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh index da5c0e258..996cc5772 100644 --- a/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/dev-fd-paths.test.sh @@ -54,3 +54,17 @@ echo piped | cat < /dev/stdin ### expect piped ### end + +### builtin_operand_dev_stdin +echo hi | cat /dev/stdin +echo w | grep w /dev/stdin +### expect +hi +w +### end + +### builtin_operand_tee_dev_stderr +echo t | tee /dev/stderr 2>&1 >/dev/null +### expect +t +### end diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index 49eea716e..d24fabe14 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -164,8 +164,16 @@ Redirection targets resolved at the **interpreter level** (`dev_fd_alias` in target, so `2>&1 >/dev/stderr` sends both streams to stdout, as on Linux. These paths never reach the VFS: writing them as regular files silently swallowed agents' `echo err > /dev/stderr`. Same `..` normalization as -`/dev/null`. Builtin file operands (`tee /dev/stderr`, `cat /dev/stdin`) -are not aliased yet. +`/dev/null`. + +Builtin file operands (`tee /dev/stderr`, `cat /dev/stdin`, `grep x +/dev/fd/0`) go through `StdStreamsFs` (`fs/std_streams.rs`): when an argument +names one of these paths, the builtin's VFS view is wrapped so reads of fd 0 +return the command's stdin and writes to fd 1/2 are captured and appended to +the builtin's stdout/stderr. Writes land after the builtin's own output on the +same stream (`tee /dev/stdout` prints its copies back to back, as through a +pipe). Only fds 0-2: `/dev/fd/63` stays a real file for process +substitution. #### /dev/urandom and /dev/random Handled at filesystem level: return 8192 bytes of random data per read From 1729f0f1e394d0e4ccf82fcf13ddaa29aea95207 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 20:01:13 +0000 Subject: [PATCH 4/5] chore(knowledge): document synthetic shell identity variables Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- knowledge/security/threat-model.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 18f87bdd7..273e2aa66 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -636,6 +636,11 @@ Bash::builder() **Current Risk**: NONE. Implementation: `builtins/system.rs`, `hostname` (default "bashkit-sandbox"), `uname` (hardcoded Linux 5.15.0), `whoami` (default "sandbox"), `id` (uid/gid 1000), all configurable via `Bash::builder().username(..).hostname(..)`. +Shell identity variables are synthetic constants, never host values: +`PATH=/usr/local/bin:/usr/bin:/bin` (`DEFAULT_PATH`), `SHELL=/bin/bash`, +`SHLVL=1`, `OSTYPE=linux-gnu`, `HOSTTYPE=x86_64`, +`MACHTYPE=x86_64-pc-linux-gnu`, `PPID=0`. Builder `env(..)` overrides them. +Covered by `threat_env_vars_explicit_only`. #### 3.3 Network Exfiltration From b5404bc81e3ceda5d351cb9a71e1c4e7ad0a4444 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 20:35:39 +0000 Subject: [PATCH 5/5] fix(interpreter): move compound-command arm out of execute_command frame Each $(...) nesting level re-enters execute_command; its poll frame held the compound arm's temporaries (15.5 KB/level in debug). The arm now runs in its own boxed future, cutting the frame to 6.2 KB, so the depth-32 stack regression test keeps headroom under coverage instrumentation. Python TM-INF-002 test expects the synthetic default PATH. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit-python/tests/_security_core.py | 4 +- crates/bashkit/src/interpreter/mod.rs | 173 ++++++++++-------- 2 files changed, 97 insertions(+), 80 deletions(-) diff --git a/crates/bashkit-python/tests/_security_core.py b/crates/bashkit-python/tests/_security_core.py index 359869d79..c1c45b396 100644 --- a/crates/bashkit-python/tests/_security_core.py +++ b/crates/bashkit-python/tests/_security_core.py @@ -297,7 +297,9 @@ def test_tm_inf_002_default_env_vars_are_sandboxed(): """TM-INF-002: builtin shell vars should resolve to virtual sandbox values.""" r = Bash().execute_sync('echo "HOME=$HOME PATH=$PATH USER=$USER HOSTNAME=$HOSTNAME"') assert r.exit_code == 0 - assert r.stdout.strip() == "HOME=/home/sandbox PATH= USER=sandbox HOSTNAME=bashkit-sandbox" + assert ( + r.stdout.strip() == "HOME=/home/sandbox PATH=/usr/local/bin:/usr/bin:/bin USER=sandbox HOSTNAME=bashkit-sandbox" + ) def test_tm_esc_002_process_substitution_stays_in_sandbox(): diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index f2c52f0fb..88076d0b4 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -2986,89 +2986,21 @@ impl Interpreter { match command { Command::Simple(simple) => { - let result = self.execute_simple_command(simple, None).await?; - self.set_simple_pipestatus(result.exit_code); - Ok(result) + // One Result local (not `?` + a second ExecResult copy): + // this frame repeats per `$(...)` nesting level. + let result = self.execute_simple_command(simple, None).await; + if let Ok(r) = &result { + self.set_simple_pipestatus(r.exit_code); + } + result } Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await, Command::List(list) => self.execute_list(list).await, Command::Compound(compound, redirects) => { - if let Some(stderr) = self.logic_only_redirect_error(redirects) { - return Ok(ExecResult::err(stderr, 1)); - } - - // Process input redirections before executing compound - let stdin = match self.process_input_redirections(None, redirects).await { - Ok(s) => s, - Err(crate::error::Error::CommandFailure(msg)) => { - return Ok(ExecResult::err(msg, 1)); - } - Err(e) => return Err(e), - }; - let prev_pipeline_stdin = if stdin.is_some() { - let prev = self.pipeline_stdin.take(); - self.pipeline_stdin = stdin; - Some(prev) - } else { - None - }; - - // Suspend output callback while output redirects are active - // so that maybe_emit_output inside the compound body does not - // leak output that will be redirected (e.g. `{ cmd; } 2>/dev/null`). - 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 - }; - - let has_dup_output = - redirects.iter().any(|r| r.kind == RedirectKind::DupOutput); - let has_file_redirect = redirects.iter().any(|r| { - matches!( - r.kind, - RedirectKind::Output - | RedirectKind::Clobber - | RedirectKind::Append - | RedirectKind::OutputBoth - ) - }); - let capture_pending_fd = has_dup_output && has_file_redirect; - if capture_pending_fd { - if self.pending_fd_capture_depth == 0 { - self.clear_pending_fd_redirect_state(); - } - self.pending_fd_capture_depth += 1; - } - let result = self.execute_compound(compound).await; - if capture_pending_fd { - self.pending_fd_capture_depth = - self.pending_fd_capture_depth.saturating_sub(1); - if result.is_err() { - self.clear_pending_fd_redirect_state(); - } - } - let result = result?; - - // Restore callback before applying redirections - if let Some(cb) = saved_callback { - self.output_callback = Some(cb); - } - - if let Some(prev) = prev_pipeline_stdin { - self.pipeline_stdin = prev; - } - if redirects.is_empty() { - Ok(result) - } else { - self.apply_redirections(result, redirects).await - } + // Own frame: keeps this arm's temporaries off the stack + // of every `$(...)`/function nesting level. + self.execute_compound_with_redirects(compound, redirects) + .await } Command::Function(func_def) => { // THREAT[TM-DOS-060]: Check function count/size budget @@ -9103,6 +9035,89 @@ impl Interpreter { .then_some(cmd.redirects.as_slice()) } + fn execute_compound_with_redirects<'a>( + &'a mut self, + compound: &'a CompoundCommand, + redirects: &'a [Redirect], + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + if let Some(stderr) = self.logic_only_redirect_error(redirects) { + return Ok(ExecResult::err(stderr, 1)); + } + + // Process input redirections before executing compound + let stdin = match self.process_input_redirections(None, redirects).await { + Ok(s) => s, + Err(crate::error::Error::CommandFailure(msg)) => { + return Ok(ExecResult::err(msg, 1)); + } + Err(e) => return Err(e), + }; + let prev_pipeline_stdin = if stdin.is_some() { + let prev = self.pipeline_stdin.take(); + self.pipeline_stdin = stdin; + Some(prev) + } else { + None + }; + + // Suspend output callback while output redirects are active + // so that maybe_emit_output inside the compound body does not + // leak output that will be redirected (e.g. `{ cmd; } 2>/dev/null`). + 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 + }; + + let has_dup_output = redirects.iter().any(|r| r.kind == RedirectKind::DupOutput); + let has_file_redirect = redirects.iter().any(|r| { + matches!( + r.kind, + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::Append + | RedirectKind::OutputBoth + ) + }); + let capture_pending_fd = has_dup_output && has_file_redirect; + if capture_pending_fd { + if self.pending_fd_capture_depth == 0 { + self.clear_pending_fd_redirect_state(); + } + self.pending_fd_capture_depth += 1; + } + let result = self.execute_compound(compound).await; + if capture_pending_fd { + self.pending_fd_capture_depth = self.pending_fd_capture_depth.saturating_sub(1); + if result.is_err() { + self.clear_pending_fd_redirect_state(); + } + } + let result = result?; + + // Restore callback before applying redirections + if let Some(cb) = saved_callback { + self.output_callback = Some(cb); + } + + if let Some(prev) = prev_pipeline_stdin { + self.pipeline_stdin = prev; + } + if redirects.is_empty() { + Ok(result) + } else { + self.apply_redirections(result, redirects).await + } + }) + } + fn execute_cmd_subst<'a>( &'a mut self, commands: &'a [Command],