From dc35b9aea9b84bfc5e7912d6127bc85f746367b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 06:22:25 +0000 Subject: [PATCH 1/2] feat(interpreter): support executing script files by path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add script execution by absolute/relative path and $PATH search. Previously, `/path/to/script.sh` returned "command not found" (exit 127) instead of executing the script. Changes: - Extract try_execute_script_by_path() for path-based dispatch with proper error handling: stat → dir check → perm check → read → execute - Extract try_execute_script_via_path_search() for $PATH lookup - Extract execute_script_content() shared helper: strips shebang, pushes call frame ($0, $1..N), parses, executes - Fix monty dependency version (0.0.6 → 0.0.7) - Update specs and docs Closes: script execution by path issue https://claude.ai/code/session_017ATUwPtZ4y542dF4tn9wRU --- crates/bashkit/Cargo.toml | 2 +- crates/bashkit/docs/compatibility.md | 2 + crates/bashkit/src/interpreter/mod.rs | 207 ++++++++--- .../bashkit/tests/script_execution_tests.rs | 320 ++++++++++++++++++ .../tests/spec_cases/bash/script-exec.test.sh | 105 ++++++ specs/005-builtins.md | 14 + specs/006-threat-model.md | 11 +- specs/008-posix-compliance.md | 4 +- specs/009-implementation-status.md | 2 + 9 files changed, 626 insertions(+), 41 deletions(-) create mode 100644 crates/bashkit/tests/script_execution_tests.rs create mode 100644 crates/bashkit/tests/spec_cases/bash/script-exec.test.sh diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 602b1cc5..212f12ac 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -61,7 +61,7 @@ base64 = { workspace = true, optional = true } tracing = { workspace = true, optional = true } # Embedded Python interpreter (optional) -monty = { git = "https://github.com/pydantic/monty", version = "0.0.6", optional = true } +monty = { git = "https://github.com/pydantic/monty", version = "0.0.7", optional = true } [features] default = [] diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 5b856097..1f222cc7 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -62,6 +62,8 @@ for sandbox security reasons. See the compliance spec for details. | `local` | `VAR=value` | Local variables | | `source` | `file [args]` | Source script; loads functions/variables, PATH search, positional params | | `.` | `file [args]` | Alias for source | +| `/path/to/script.sh` | `[args]` | Execute script by absolute/relative path (shebang stripped, call frame) | +| `$PATH` search | `cmd [args]` | Search `$PATH` dirs for executable scripts (after builtins) | | `break` | `[N]` | Break from loop | | `continue` | `[N]` | Continue loop | | `return` | `[N]` | Return from function | diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index c13afa7e..1525654b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -2203,44 +2203,18 @@ impl Interpreter { // Check if command is a path to an executable script in the VFS if name.contains('/') { - let path = self.resolve_path(name); - if let Ok(content) = self.fs.read_file(&path).await { - // Check execute permission - if let Ok(meta) = self.fs.stat(&path).await { - if meta.mode & 0o111 != 0 { - let script_text = String::from_utf8_lossy(&content).to_string(); - // Strip shebang line if present - let script_text = if script_text.starts_with("#!") { - script_text - .find('\n') - .map(|pos| &script_text[pos + 1..]) - .unwrap_or("") - .to_string() - } else { - script_text - }; - let parser = Parser::with_limits( - &script_text, - self.limits.max_ast_depth, - self.limits.max_parser_operations, - ); - match parser.parse() { - Ok(script) => { - let result = self.execute(&script).await?; - return self.apply_redirections(result, &command.redirects).await; - } - Err(e) => { - return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2)); - } - } - } else { - return Ok(ExecResult::err( - format!("bash: {}: Permission denied\n", name), - 126, - )); - } - } - } + let result = self + .try_execute_script_by_path(name, &args, &command.redirects) + .await?; + return Ok(result); + } + + // No slash in name: search $PATH for executable script + if let Some(result) = self + .try_execute_script_via_path_search(name, &args, &command.redirects) + .await? + { + return Ok(result); } // Command not found - return error like bash does (exit code 127) @@ -2250,6 +2224,163 @@ impl Interpreter { )) } + /// Execute a script file by resolved path. + /// + /// Bash behavior for path-based commands (name contains `/`): + /// 1. Resolve path (absolute or relative to cwd) + /// 2. stat() — if not found: "No such file or directory" (exit 127) + /// 3. If directory: "Is a directory" (exit 126) + /// 4. If not executable (mode & 0o111 == 0): "Permission denied" (exit 126) + /// 5. Read file, strip shebang, parse, execute in call frame + async fn try_execute_script_by_path( + &mut self, + name: &str, + args: &[String], + redirects: &[Redirect], + ) -> Result { + let path = self.resolve_path(name); + + // stat the file + let meta = match self.fs.stat(&path).await { + Ok(m) => m, + Err(_) => { + return Ok(ExecResult::err( + format!("bash: {}: No such file or directory", name), + 127, + )); + } + }; + + // Directory check + if meta.file_type.is_dir() { + return Ok(ExecResult::err( + format!("bash: {}: Is a directory", name), + 126, + )); + } + + // Execute permission check + if meta.mode & 0o111 == 0 { + return Ok(ExecResult::err( + format!("bash: {}: Permission denied", name), + 126, + )); + } + + // Read file content + let content = match self.fs.read_file(&path).await { + Ok(c) => String::from_utf8_lossy(&c).to_string(), + Err(_) => { + return Ok(ExecResult::err( + format!("bash: {}: No such file or directory", name), + 127, + )); + } + }; + + self.execute_script_content(name, &content, args, redirects) + .await + } + + /// Search $PATH for an executable script and run it. + /// + /// Returns `Ok(None)` if no matching file found (caller emits "command not found"). + async fn try_execute_script_via_path_search( + &mut self, + name: &str, + args: &[String], + redirects: &[Redirect], + ) -> Result> { + let path_var = self + .variables + .get("PATH") + .or_else(|| self.env.get("PATH")) + .cloned() + .unwrap_or_default(); + + for dir in path_var.split(':') { + if dir.is_empty() { + continue; + } + let candidate = PathBuf::from(dir).join(name); + if let Ok(meta) = self.fs.stat(&candidate).await { + if meta.file_type.is_dir() { + continue; + } + if meta.mode & 0o111 == 0 { + continue; + } + if let Ok(content) = self.fs.read_file(&candidate).await { + let script_text = String::from_utf8_lossy(&content).to_string(); + let result = self + .execute_script_content(name, &script_text, args, redirects) + .await?; + return Ok(Some(result)); + } + } + } + + Ok(None) + } + + /// Parse and execute script content in a new call frame. + /// + /// Shared by path-based and $PATH-based script execution. + /// Sets up $0 = script name, $1..N = args, strips shebang. + async fn execute_script_content( + &mut self, + name: &str, + content: &str, + args: &[String], + redirects: &[Redirect], + ) -> Result { + // Strip shebang line if present + let script_text = if content.starts_with("#!") { + content + .find('\n') + .map(|pos| &content[pos + 1..]) + .unwrap_or("") + } else { + content + }; + + let parser = Parser::with_limits( + script_text, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ); + let script = match parser.parse() { + Ok(s) => s, + Err(e) => { + return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2)); + } + }; + + // Push call frame: $0 = script name, $1..N = args + self.call_stack.push(CallFrame { + name: name.to_string(), + locals: HashMap::new(), + positional: args.to_vec(), + }); + + let result = self.execute(&script).await; + + // Pop call frame + self.call_stack.pop(); + + match result { + Ok(mut exec_result) => { + // Handle return - convert Return control flow to exit code + if let ControlFlow::Return(code) = exec_result.control_flow { + exec_result.exit_code = code; + exec_result.control_flow = ControlFlow::None; + } + self.apply_redirections(exec_result, redirects).await + } + Err(e) => Err(e), + } + } + /// Execute `source` / `.` - read and execute commands from a file in current shell. /// /// Bash behavior: diff --git a/crates/bashkit/tests/script_execution_tests.rs b/crates/bashkit/tests/script_execution_tests.rs new file mode 100644 index 00000000..f961f516 --- /dev/null +++ b/crates/bashkit/tests/script_execution_tests.rs @@ -0,0 +1,320 @@ +//! Tests for executing script files by path and $PATH search. +//! +//! Covers: absolute path, relative path, arguments, shebang stripping, +//! missing file, directory, permission denied, exit code propagation, +//! nested paths, and $PATH search. + +use bashkit::Bash; +use std::path::Path; + +/// Execute script by absolute path +#[tokio::test] +async fn exec_script_by_absolute_path() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/test.sh"), b"#!/bin/bash\necho hello") + .await + .unwrap(); + fs.chmod(Path::new("/test.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/test.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "hello"); + assert_eq!(result.exit_code, 0); +} + +/// Execute script without shebang line +#[tokio::test] +async fn exec_script_without_shebang() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/no_shebang.sh"), b"echo no shebang") + .await + .unwrap(); + fs.chmod(Path::new("/no_shebang.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/no_shebang.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "no shebang"); + assert_eq!(result.exit_code, 0); +} + +/// Execute script with arguments ($1, $2) +#[tokio::test] +async fn exec_script_with_args() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file( + Path::new("/greet.sh"), + b"#!/bin/bash\necho \"Hello, $1 and $2!\"", + ) + .await + .unwrap(); + fs.chmod(Path::new("/greet.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/greet.sh world moon").await.unwrap(); + assert_eq!(result.stdout.trim(), "Hello, world and moon!"); + assert_eq!(result.exit_code, 0); +} + +/// $0 is set to the script name +#[tokio::test] +async fn exec_script_dollar_zero() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/show_name.sh"), b"#!/bin/bash\necho $0") + .await + .unwrap(); + fs.chmod(Path::new("/show_name.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/show_name.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "/show_name.sh"); + assert_eq!(result.exit_code, 0); +} + +/// Nonexistent file returns "No such file or directory" (exit 127) +#[tokio::test] +async fn exec_script_missing_file() { + let mut bash = Bash::new(); + + let result = bash.exec("/missing.sh").await.unwrap(); + assert!(result.stderr.contains("No such file or directory")); + assert_eq!(result.exit_code, 127); +} + +/// Directory returns "Is a directory" (exit 126) +#[tokio::test] +async fn exec_script_is_directory() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.mkdir(Path::new("/mydir"), false).await.unwrap(); + + let result = bash.exec("/mydir").await.unwrap(); + assert!(result.stderr.contains("Is a directory")); + assert_eq!(result.exit_code, 126); +} + +/// Not executable returns "Permission denied" (exit 126) +#[tokio::test] +async fn exec_script_permission_denied() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/noperm.sh"), b"echo nope") + .await + .unwrap(); + // Default mode is 0o644 — not executable + + let result = bash.exec("/noperm.sh").await.unwrap(); + assert!(result.stderr.contains("Permission denied")); + assert_eq!(result.exit_code, 126); +} + +/// Exit code propagation from script +#[tokio::test] +async fn exec_script_exit_code_propagation() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/fail.sh"), b"#!/bin/bash\nexit 42") + .await + .unwrap(); + fs.chmod(Path::new("/fail.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/fail.sh\necho $?").await.unwrap(); + assert_eq!(result.stdout.trim(), "42"); +} + +/// Nested directory paths work +#[tokio::test] +async fn exec_script_nested_path() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.mkdir(Path::new("/workspace/.agents/skills/nav/scripts"), true) + .await + .unwrap(); + fs.write_file( + Path::new("/workspace/.agents/skills/nav/scripts/nav.sh"), + b"#!/bin/bash\necho \"nav: $1\"", + ) + .await + .unwrap(); + fs.chmod( + Path::new("/workspace/.agents/skills/nav/scripts/nav.sh"), + 0o755, + ) + .await + .unwrap(); + + let result = bash + .exec("/workspace/.agents/skills/nav/scripts/nav.sh dist") + .await + .unwrap(); + assert_eq!(result.stdout.trim(), "nav: dist"); + assert_eq!(result.exit_code, 0); +} + +/// $PATH search finds and executes script +#[tokio::test] +async fn exec_script_via_path_search() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.mkdir(Path::new("/usr/local/bin"), true).await.unwrap(); + fs.write_file( + Path::new("/usr/local/bin/myscript"), + b"#!/bin/bash\necho found", + ) + .await + .unwrap(); + fs.chmod(Path::new("/usr/local/bin/myscript"), 0o755) + .await + .unwrap(); + + let result = bash.exec("PATH=/usr/local/bin\nmyscript").await.unwrap(); + assert_eq!(result.stdout.trim(), "found"); + assert_eq!(result.exit_code, 0); +} + +/// $PATH search skips non-executable files +#[tokio::test] +async fn path_search_skips_non_executable() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.mkdir(Path::new("/bin1"), false).await.unwrap(); + fs.mkdir(Path::new("/bin2"), false).await.unwrap(); + // /bin1/cmd exists but not executable + fs.write_file(Path::new("/bin1/cmd"), b"echo wrong") + .await + .unwrap(); + // /bin2/cmd is executable + fs.write_file(Path::new("/bin2/cmd"), b"echo right") + .await + .unwrap(); + fs.chmod(Path::new("/bin2/cmd"), 0o755).await.unwrap(); + + let result = bash.exec("PATH=/bin1:/bin2\ncmd").await.unwrap(); + assert_eq!(result.stdout.trim(), "right"); +} + +/// $PATH search returns "command not found" when no match +#[tokio::test] +async fn path_search_command_not_found() { + let mut bash = Bash::new(); + + let result = bash.exec("PATH=\nnosuchcmd").await.unwrap(); + assert!(result.stderr.contains("command not found")); + assert_eq!(result.exit_code, 127); +} + +/// Script with relative path (contains /) +#[tokio::test] +async fn exec_script_relative_path() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.mkdir(Path::new("/workspace"), false).await.unwrap(); + fs.write_file(Path::new("/workspace/run.sh"), b"echo relative works") + .await + .unwrap(); + fs.chmod(Path::new("/workspace/run.sh"), 0o755) + .await + .unwrap(); + + // Set cwd to /workspace so ./run.sh resolves + let result = bash.exec("cd /workspace\n./run.sh").await.unwrap(); + assert_eq!(result.stdout.trim(), "relative works"); + assert_eq!(result.exit_code, 0); +} + +/// Script calls another script by path +#[tokio::test] +async fn exec_script_calls_script() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/inner.sh"), b"#!/bin/bash\necho inner") + .await + .unwrap(); + fs.chmod(Path::new("/inner.sh"), 0o755).await.unwrap(); + + fs.write_file( + Path::new("/outer.sh"), + b"#!/bin/bash\necho outer\n/inner.sh", + ) + .await + .unwrap(); + fs.chmod(Path::new("/outer.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/outer.sh").await.unwrap(); + assert_eq!(result.stdout, "outer\ninner\n"); + assert_eq!(result.exit_code, 0); +} + +/// Script written via echo/redirect then chmod +x then executed +#[tokio::test] +async fn exec_script_chmod_then_run() { + let mut bash = Bash::new(); + + let result = bash + .exec( + "echo '#!/bin/bash\necho script ran' > /tmp/test_exec.sh\n\ + chmod +x /tmp/test_exec.sh\n\ + /tmp/test_exec.sh", + ) + .await + .unwrap(); + assert_eq!(result.stdout.trim(), "script ran"); + assert_eq!(result.exit_code, 0); +} + +/// $PATH search with args +#[tokio::test] +async fn path_search_with_args() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.mkdir(Path::new("/mybin"), false).await.unwrap(); + fs.write_file(Path::new("/mybin/greeter"), b"#!/bin/bash\necho \"hi $1\"") + .await + .unwrap(); + fs.chmod(Path::new("/mybin/greeter"), 0o755).await.unwrap(); + + let result = bash.exec("PATH=/mybin\ngreeter alice").await.unwrap(); + assert_eq!(result.stdout.trim(), "hi alice"); +} + +/// Script $# shows argument count +#[tokio::test] +async fn exec_script_dollar_hash() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/count.sh"), b"#!/bin/bash\necho $#") + .await + .unwrap(); + fs.chmod(Path::new("/count.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/count.sh a b c").await.unwrap(); + assert_eq!(result.stdout.trim(), "3"); +} + +/// Script $@ shows all arguments +#[tokio::test] +async fn exec_script_dollar_at() { + let mut bash = Bash::new(); + let fs = bash.fs(); + + fs.write_file(Path::new("/all.sh"), b"#!/bin/bash\necho $@") + .await + .unwrap(); + fs.chmod(Path::new("/all.sh"), 0o755).await.unwrap(); + + let result = bash.exec("/all.sh x y z").await.unwrap(); + assert_eq!(result.stdout.trim(), "x y z"); +} diff --git a/crates/bashkit/tests/spec_cases/bash/script-exec.test.sh b/crates/bashkit/tests/spec_cases/bash/script-exec.test.sh new file mode 100644 index 00000000..bb7ef3c5 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/script-exec.test.sh @@ -0,0 +1,105 @@ +### script_exec_absolute_path +# Execute script by absolute path +echo '#!/bin/bash +echo hello from script' > /tmp/s.sh +chmod +x /tmp/s.sh +/tmp/s.sh +### expect +hello from script +### end + +### script_exec_with_args +# Script receives positional args +echo '#!/bin/bash +echo "arg1=$1 arg2=$2"' > /tmp/args.sh +chmod +x /tmp/args.sh +/tmp/args.sh foo bar +### expect +arg1=foo arg2=bar +### end + +### script_exec_dollar_zero +# $0 is set to script path +echo '#!/bin/bash +echo $0' > /tmp/name.sh +chmod +x /tmp/name.sh +/tmp/name.sh +### expect +/tmp/name.sh +### end + +### script_exec_dollar_hash +# $# shows argument count +echo '#!/bin/bash +echo $#' > /tmp/cnt.sh +chmod +x /tmp/cnt.sh +/tmp/cnt.sh a b c +### expect +3 +### end + +### script_exec_exit_code +# Exit code propagates from script +echo '#!/bin/bash +exit 42' > /tmp/ex.sh +chmod +x /tmp/ex.sh +/tmp/ex.sh +echo $? +### expect +42 +### end + +### script_exec_no_shebang +# Script without shebang still runs +echo 'echo no shebang' > /tmp/ns.sh +chmod +x /tmp/ns.sh +/tmp/ns.sh +### expect +no shebang +### end + +### script_exec_missing_file +# Missing file: exit 127 +/tmp/nonexistent_script.sh +echo $? +### expect +127 +### exit_code: 0 +### end + +### script_exec_permission_denied +# No execute permission: exit 126 +echo 'echo nope' > /tmp/nox.sh +/tmp/nox.sh +echo $? +### expect +126 +### exit_code: 0 +### end + +### script_exec_nested_call +# Script calling another script +echo '#!/bin/bash +echo inner' > /tmp/inner.sh +chmod +x /tmp/inner.sh +echo '#!/bin/bash +echo outer +/tmp/inner.sh' > /tmp/outer.sh +chmod +x /tmp/outer.sh +/tmp/outer.sh +### expect +outer +inner +### end + +### script_exec_path_search +# $PATH search finds executable +mkdir -p /usr/local/bin +echo '#!/bin/bash +echo found via path' > /usr/local/bin/myutil +chmod +x /usr/local/bin/myutil +PATH=/usr/local/bin +myutil +### expect +found via path +### end diff --git a/specs/005-builtins.md b/specs/005-builtins.md index 4b4195e7..fa63425c 100644 --- a/specs/005-builtins.md +++ b/specs/005-builtins.md @@ -21,6 +21,20 @@ in a virtual environment. All builtins operate on the virtual filesystem. - `test`, `[` - Conditionals (see Test Operators below) - `read` - Input +#### Script Execution by Path + +Commands containing `/` (absolute or relative paths) are resolved against the +VFS. Commands without `/` are searched in `$PATH` directories for executable +files. The dispatch order is: functions → special commands → builtins → path +execution → $PATH search → "command not found". + +- Absolute: `/path/to/script.sh` — resolved directly +- Relative: `./script.sh` — resolved relative to cwd +- $PATH search: `myscript` — searches each `$PATH` directory for executable file +- Shebang (`#!/bin/bash`) stripped; content executed as bash +- `$0` = script name, `$1..N` = arguments +- Exit 127: file not found; Exit 126: not executable or is a directory + #### Test Operators (`test` / `[`) **String tests:** diff --git a/specs/006-threat-model.md b/specs/006-threat-model.md index 5d32658b..eb80ef7d 100644 --- a/specs/006-threat-model.md +++ b/specs/006-threat-model.md @@ -257,7 +257,7 @@ max_parser_operations: 100_000, // Parser fuel (TM-DOS-024) | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| | TM-ESC-005 | Shell escape | `exec /bin/bash` | exec not implemented (returns exit 127) | **MITIGATED** | -| TM-ESC-006 | Subprocess | `./malicious` | External exec disabled (returns exit 127) | **MITIGATED** | +| TM-ESC-006 | Subprocess | `./malicious` | Script execution runs within VFS sandbox (no host shell) | **MITIGATED** | | TM-ESC-007 | Background proc | `malicious &` | Background not implemented | **MITIGATED** | | TM-ESC-008 | eval injection | `eval "$user_input"` | eval runs in sandbox (builtins only) | **MITIGATED** | | TM-ESC-015 | bash/sh escape | `bash -c "malicious"` | Sandboxed re-invocation (no external bash) | **MITIGATED** | @@ -278,6 +278,15 @@ processes. This enables common patterns while maintaining security: - Resource limits and virtual filesystem are shared with parent - No escape to host shell is possible +**Script Execution by Path** (TM-ESC-006): Scripts can be executed by absolute +path (`/path/to/script.sh`), relative path (`./script.sh`), or `$PATH` search. +All execution stays within the virtual interpreter — no OS subprocess is spawned: +- File must exist in VFS and have execute permission (mode & 0o111) +- Exit 127 for missing files, exit 126 for non-executable or directories +- Shebang line stripped; content parsed and executed as bash +- `$0` = script name, `$1..N` = arguments via call frame +- Resource limits and VFS constraints apply to executed scripts + #### 2.3 Privilege Escalation | ID | Threat | Attack Vector | Mitigation | Status | diff --git a/specs/008-posix-compliance.md b/specs/008-posix-compliance.md index 163bdb60..1918b1cc 100644 --- a/specs/008-posix-compliance.md +++ b/specs/008-posix-compliance.md @@ -35,7 +35,9 @@ should handle errors through exit codes and conditional execution. ### Security-Motivated -1. **No process spawning**: External commands run as builtins, not subprocesses +1. **No OS process spawning**: External commands run as builtins or virtual script + re-invocations, not OS subprocesses. Scripts can be executed by absolute path, + relative path, or `$PATH` search within the VFS. 2. **No signal handling**: `trap` excluded for sandbox isolation 3. **No process replacement**: `exec` excluded for containment 4. **Virtual filesystem**: Real FS access requires explicit configuration diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index d8f420c4..0a52e494 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -159,6 +159,8 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | timeout.test.sh | 17 | | | variables.test.sh | 44 | includes special vars, prefix env assignments | | wc.test.sh | 35 | word count (5 skipped) | +| eval-bugs.test.sh | 3 | regression tests for eval/script bugs | +| script-exec.test.sh | 10 | script execution by path, $PATH search, exit codes | ## Shell Features From 43e75d29e128fa3c42cdbe4c6f4958b3d7ef1e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 06:36:07 +0000 Subject: [PATCH 2/2] chore: update cargo-vet exemptions for dependency bumps Bump exemption versions for: anyhow, bumpalo, clap, clap_builder, security-framework, security-framework-sys, syn. https://claude.ai/code/session_017ATUwPtZ4y542dF4tn9wRU --- supply-chain/config.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index cedce595..300d8bf2 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -63,7 +63,7 @@ version = "3.0.11" criteria = "safe-to-deploy" [[exemptions.anyhow]] -version = "1.0.101" +version = "1.0.102" criteria = "safe-to-deploy" [[exemptions.approx]] @@ -123,7 +123,7 @@ version = "1.12.1" criteria = "safe-to-deploy" [[exemptions.bumpalo]] -version = "3.20.1" +version = "3.20.2" criteria = "safe-to-deploy" [[exemptions.bytecount]] @@ -183,11 +183,11 @@ version = "0.2.2" criteria = "safe-to-run" [[exemptions.clap]] -version = "4.5.59" +version = "4.5.60" criteria = "safe-to-deploy" [[exemptions.clap_builder]] -version = "4.5.59" +version = "4.5.60" criteria = "safe-to-deploy" [[exemptions.clap_derive]] @@ -1039,11 +1039,11 @@ version = "3.0.10" criteria = "safe-to-run" [[exemptions.security-framework]] -version = "3.6.0" +version = "3.7.0" criteria = "safe-to-deploy" [[exemptions.security-framework-sys]] -version = "2.16.0" +version = "2.17.0" criteria = "safe-to-deploy" [[exemptions.semver]] @@ -1147,7 +1147,7 @@ version = "2.6.1" criteria = "safe-to-deploy" [[exemptions.syn]] -version = "2.0.116" +version = "2.0.117" criteria = "safe-to-deploy" [[exemptions.sync_wrapper]]