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
145 changes: 144 additions & 1 deletion crates/bashkit/src/builtins/sysextra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@ enum RunAsKind {
Nohup,
Nice,
Flock,
Sudo,
Busybox,
}

impl RunAs {
Expand All @@ -504,11 +506,27 @@ impl RunAs {
}
}

/// `sudo [OPTIONS] [VAR=VALUE]... COMMAND [ARG]...`: the sandbox has
/// one user and no privilege boundary, so the command just runs.
pub fn sudo() -> Self {
Self {
kind: RunAsKind::Sudo,
}
}
/// `busybox APPLET [ARG]...`: run the builtin of that name.
pub fn busybox() -> Self {
Self {
kind: RunAsKind::Busybox,
}
}

fn name(&self) -> &'static str {
match self.kind {
RunAsKind::Nohup => "nohup",
RunAsKind::Nice => "nice",
RunAsKind::Flock => "flock",
RunAsKind::Sudo => "sudo",
RunAsKind::Busybox => "busybox",
}
}
}
Expand All @@ -526,6 +544,15 @@ enum Run {

fn parse_runner(kind: RunAsKind, args: &[String]) -> Run {
match kind {
RunAsKind::Sudo => parse_sudo(args),
RunAsKind::Busybox => match args.split_first() {
None => Run::Done(ExecResult::err(BUSYBOX_USAGE.to_string(), 1)),
Some((applet, rest)) => {
// `busybox /bin/ls` and `busybox ls` name the same applet.
let applet = applet.rsplit('/').next().unwrap_or(applet);
Run::Command(applet.to_string(), rest.to_vec())
}
},
RunAsKind::Nohup => {
let args = match args.first().map(String::as_str) {
Some("--") => &args[1..],
Expand Down Expand Up @@ -642,6 +669,96 @@ fn parse_runner(kind: RunAsKind, args: &[String]) -> Run {
}
}

const BUSYBOX_USAGE: &str = "BusyBox v1.36.1 (bashkit) multi-call binary.\n\nUsage: busybox [function [arguments]...]\n or: busybox --list\n or: function [arguments]...\n\n\tBusyBox is a multi-call binary that combines many common Unix\n\tutilities into a single executable. In bashkit every applet is\n\tthe sandbox's own builtin of the same name.\n";

/// `sudo`: skip options (identity, environment, prompt and timestamp
/// options have no meaning with one sandbox user), then run the command.
/// Leading `VAR=VALUE` operands go through `env`, as sudo sets them for the
/// command only.
fn parse_sudo(args: &[String]) -> Run {
let mut i = 0;
let mut shell = false;
while i < args.len() {
let a = args[i].as_str();
match a {
"--" => {
i += 1;
break;
}
// Options with a value.
"-u" | "--user" | "-g" | "--group" | "-C" | "--close-from" | "-p" | "--prompt"
| "-h" | "--host" | "-r" | "--role" | "-t" | "--type" | "-U" | "--other-user"
| "-D" | "--chdir" | "-T" | "--command-timeout" => i += 2,
"-s" | "--shell" | "-i" | "--login" => {
shell = true;
i += 1;
}
// Validate/refresh/forget credentials: nothing to do.
"-v" | "--validate" | "-k" | "--reset-timestamp" | "-K" | "--remove-timestamp"
if args.len() == i + 1 =>
{
return Run::Done(ExecResult::ok(String::new()));
}
"-l" | "--list" => {
return Run::Done(ExecResult::ok(
"User sandbox may run the following commands on bashkit-sandbox:\n (ALL) NOPASSWD: ALL\n"
.to_string(),
));
}
_ if a.starts_with("--") && a.len() > 2 => i += 1,
_ if a.starts_with('-') && a.len() > 1 => {
// Clustered short flags (`-nE`); a value flag at the end of
// the cluster takes the next operand (`-nu root`).
if a[1..].contains(['s', 'i']) {
shell = true;
}
let last = a.as_bytes()[a.len() - 1];
i += if b"ugCphrtUDT".contains(&last) { 2 } else { 1 };
}
_ => break,
}
}
let rest = args.get(i..).unwrap_or_default();
let assigns = rest
.iter()
.take_while(|a| a.split_once('=').is_some_and(|(k, _)| is_var_name(k)))
.count();
let (vars, command) = rest.split_at(assigns);
let Some((cmd, cmd_args)) = command.split_first() else {
if shell {
// An interactive root shell: nothing to read here.
return Run::Done(ExecResult::ok(String::new()));
}
return Run::Done(ExecResult::err(
"usage: sudo [-u user] [VAR=value] command [arg ...]\n".to_string(),
1,
));
};
let (cmd, cmd_args) = if shell {
// `sudo -s cmd args` runs `$SHELL -c 'cmd args'`.
let script = command.join(" ");
("bash".to_string(), vec!["-c".to_string(), script])
} else {
(cmd.clone(), cmd_args.to_vec())
};
if vars.is_empty() {
Run::Command(cmd, cmd_args)
} else {
let mut env_args = vars.to_vec();
env_args.push(cmd);
env_args.extend(cmd_args);
Run::Command("env".to_string(), env_args)
}
}

fn is_var_name(s: &str) -> bool {
let mut chars = s.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

async fn create_lock_file(ctx: &Context<'_>, file: &str) -> Result<()> {
let path = super::resolve_path(ctx.cwd, file);
if ctx.fs.exists(&path).await.unwrap_or(false) {
Expand All @@ -663,7 +780,24 @@ impl Builtin for RunAs {
RunAsKind::Flock => {
"Usage: flock [options] <file>|<directory> <command> [<argument>...]\n flock [options] <file>|<directory> -c <command>\n flock [options] <file descriptor number>\nManage file locks from shell scripts.\n"
}
RunAsKind::Sudo => {
"usage: sudo [-u user] [-E] [-n] [VAR=value] command [arg ...]\n sudo -s|-i [command]\n sudo -l | -v | -k\nThe sandbox has a single user; sudo runs the command as is.\n"
}
RunAsKind::Busybox => BUSYBOX_USAGE,
};
if self.kind == RunAsKind::Busybox
&& let Some(first) = ctx.args.first()
&& matches!(first.as_str(), "--list" | "--list-full" | "-l")
{
let names = ctx
.shell
.as_ref()
.map(|shell| shell.builtin_names())
.unwrap_or_default();
let prefix = if first == "--list-full" { "bin/" } else { "" };
let list: String = names.iter().map(|n| format!("{prefix}{n}\n")).collect();
return Ok(ExecResult::ok(list));
}
if let Some(r) = help(&ctx, self.name(), usage) {
return Ok(r);
}
Expand All @@ -683,7 +817,16 @@ impl Builtin for RunAs {
}

async fn execution_plan(&self, ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
if ctx.args.iter().any(|a| a == "--help" || a == "--version") {
// Only the runner's own leading options: `nohup tar --help` runs tar.
if super::check_help_version(ctx.args, "", Some("")).is_some() {
return Ok(None);
}
if self.kind == RunAsKind::Busybox
&& ctx
.args
.first()
.is_some_and(|a| matches!(a.as_str(), "--list" | "--list-full" | "-l"))
{
return Ok(None);
}
let mut run = parse_runner(self.kind, ctx.args);
Expand Down
4 changes: 3 additions & 1 deletion crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ fn unavailable_command_hint(name: &str) -> Option<&'static str> {
"npm" | "yarn" | "pnpm" | "bun" => {
Some("Package managers are not available in the sandbox.")
}
"sudo" | "su" | "doas" => Some("All commands run without privilege restrictions."),
"su" | "doas" => Some("All commands run without privilege restrictions."),
#[cfg(not(feature = "ssh"))]
"ssh" | "scp" | "sftp" => {
Some("SSH requires the 'ssh' feature. Enable with: features = [\"ssh\"]")
Expand Down Expand Up @@ -1826,6 +1826,8 @@ impl Interpreter {
builtins.insert("nohup".to_string(), Arc::new(builtins::RunAs::nohup()));
builtins.insert("nice".to_string(), Arc::new(builtins::RunAs::nice()));
builtins.insert("flock".to_string(), Arc::new(builtins::RunAs::flock()));
builtins.insert("sudo".to_string(), Arc::new(builtins::RunAs::sudo()));
builtins.insert("busybox".to_string(), Arc::new(builtins::RunAs::busybox()));
builtins.insert("egrep".to_string(), Arc::new(builtins::GrepAlias::egrep()));
builtins.insert("fgrep".to_string(), Arc::new(builtins::GrepAlias::fgrep()));
builtins.insert("link".to_string(), Arc::new(builtins::Link::hard()));
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit/tests/integration/script_execution_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ async fn command_not_found_sandbox_hint() {
assert_eq!(result.exit_code, 127);
assert!(result.stderr.contains("Package managers"));

let result = bash.exec("sudo ls").await.unwrap();
let result = bash.exec("doas ls").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(result.stderr.contains("privilege"));

Expand Down
6 changes: 3 additions & 3 deletions crates/bashkit/tests/spec_cases/bash/cmd-suggestions.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ pip install foo
### expect
### end

### cmd_unavailable_sudo
### cmd_unavailable_doas
### bash_diff: bashkit hints for unavailable commands
### exit_code: 127
# sudo gets helpful hint
sudo ls
# doas gets helpful hint (sudo runs the command)
doas ls
### expect
### end

Expand Down
56 changes: 56 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/sudo-busybox.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# sudo runs its command (one sandbox user, no privilege boundary) and
# busybox runs the builtin named by its first argument. Agent scripts use
# both; without them the whole command failed with 127.

### sudo_runs_command
### bash_diff: sudo on the reference host may prompt or change identity
sudo tee /tmp/sudo.conf > /dev/null <<EOF
key=1
EOF
cat /tmp/sudo.conf
sudo -E sh -c 'echo from-shell'
sudo -n mkdir -p /tmp/sudo-dir && echo made
### expect
key=1
from-shell
made
### end

### sudo_env_assignment_and_shell
### bash_diff: sudo on the reference host may prompt or change identity
sudo FOO=bar printenv FOO
sudo -s echo via -s
sudo -v; echo "v=$?"
### expect
bar
via -s
v=0
### end

### sudo_missing_command
### bash_diff: sudo on the reference host may prompt or change identity
sudo 2>/dev/null; echo "rc=$?"
### expect
rc=1
### end

### busybox_runs_applet
### bash_diff: busybox is not installed on the reference host
busybox echo hi
busybox /bin/echo path
printf 'b\na\n' | busybox sort
busybox --list | grep -qx tar && echo listed
### expect
hi
path
a
b
listed
### end

### runner_help_belongs_to_command
### bash_diff: help text differs from GNU coreutils
nohup tar --help | head -1
### expect
Usage: tar [OPTION]... [FILE]...
### end
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2026-10-07

* **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <<EOF`, `busybox tar --help`). `sudo` skips its options and runs the command (leading `VAR=VALUE` through `env`, `-s`/`-i` through `bash -c`; `-v`/`-k` succeed, `-l` lists ALL); `busybox APPLET` runs the builtin of that name, `--list` lists them (L-SUDO-001). `nohup`/`nice`/`flock` no longer steal the inner command's `--help`.
* **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path.
* **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches.
* **Fix**: A backslash outside quotes keeps `$` and `` ` `` literal. `echo \$HOME` printed the home directory; it now prints `$HOME`, as do `a\$x`, `\${x}` and `x=\$y` (the lexer emits the same NUL literal marker as quoted `"\$"`). Found while working gap telemetry.
Expand Down
1 change: 1 addition & 0 deletions knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance`
| 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-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: `yes \| head -1` gives `PIPESTATUS` `1 0` plus yes's output-cap notice, where bash gives `141 0`. Downstream of a streaming stage, a single builtin reads all its input before it starts, except `head` and `read`: `while :; do echo; done \| cat \| 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` |
| L-RAND-001 | `uuidgen` makes random (v4) UUIDs only (no `-t`, `-m`, `-s`); `openssl` implements only `rand` | Time/MAC-based UUIDs would leak host identity; a full TLS/crypto toolkit is out of scope | `uuidgen_time_based_unsupported` |
| L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` (and `link`) makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` |
| L-ROOTFS-001 | Default rootfs is static and read-only: `/proc` has no `self`, pid dirs, `uptime` or live counters; `/etc/passwd` has no root entry; `/dev/zero` yields 1 MiB per read; `/bin`, `/usr/bin` are stubs that dispatch builtins | Host state must not leak (TM-INF-003, TM-ISO-018); fixed values keep runs deterministic | `rootfs_layout`, `threat_etc_passwd_blocked` |
Expand Down
2 changes: 1 addition & 1 deletion knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ execute permission (mode & 0o111); exit 127 missing / 126 non-executable; sheban

| ID | Threat | Attack Vector | Mitigation | Status |
|----|--------|--------------|------------|--------|
| TM-ESC-009 | sudo/su | `sudo rm -rf /` | Not implemented | **MITIGATED** |
| TM-ESC-009 | sudo/su | `sudo rm -rf /` | `sudo` runs the command as the same sandbox user (no identity or permission change, L-SUDO-001); `su`/`doas` not implemented | **MITIGATED** |
| TM-ESC-010 | setuid | Permission changes | Virtual FS, no real perms | **MITIGATED** |
| TM-ESC-011 | Capability abuse | Linux capabilities | Runs in-process | **MITIGATED** |

Expand Down
10 changes: 9 additions & 1 deletion knowledge/status/builtins.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
"feature": null,
"name": "bunzip2"
},
{
"feature": null,
"name": "busybox"
},
{
"feature": null,
"name": "bzcat"
Expand Down Expand Up @@ -697,6 +701,10 @@
"feature": null,
"name": "strings"
},
{
"feature": null,
"name": "sudo"
},
{
"feature": null,
"name": "sum"
Expand Down Expand Up @@ -894,5 +902,5 @@
"name": "zip"
}
],
"count": 223
"count": 225
}
Loading