From e8eb807d202880a0eebc14d3180719f6eb66ed9d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 14:03:28 +0000 Subject: [PATCH] feat(builtins): sudo and busybox run their command Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/builtins/sysextra.rs | 145 +++++++++++++++++- crates/bashkit/src/interpreter/mod.rs | 4 +- .../integration/script_execution_tests.rs | 2 +- .../spec_cases/bash/cmd-suggestions.test.sh | 6 +- .../spec_cases/bash/sudo-busybox.test.sh | 56 +++++++ knowledge/log.md | 1 + knowledge/operations/limitations.md | 1 + knowledge/security/threat-model.md | 2 +- knowledge/status/builtins.json | 10 +- 9 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/sudo-busybox.test.sh diff --git a/crates/bashkit/src/builtins/sysextra.rs b/crates/bashkit/src/builtins/sysextra.rs index bf70a1d6a..e6cc3ce66 100644 --- a/crates/bashkit/src/builtins/sysextra.rs +++ b/crates/bashkit/src/builtins/sysextra.rs @@ -482,6 +482,8 @@ enum RunAsKind { Nohup, Nice, Flock, + Sudo, + Busybox, } impl RunAs { @@ -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", } } } @@ -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..], @@ -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) { @@ -663,7 +780,24 @@ impl Builtin for RunAs { RunAsKind::Flock => { "Usage: flock [options] | [...]\n flock [options] | -c \n flock [options] \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); } @@ -683,7 +817,16 @@ impl Builtin for RunAs { } async fn execution_plan(&self, ctx: &Context<'_>) -> Result> { - 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); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 07d40e436..44e8c8960 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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\"]") @@ -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())); diff --git a/crates/bashkit/tests/integration/script_execution_tests.rs b/crates/bashkit/tests/integration/script_execution_tests.rs index b279d8514..59a8eb73d 100644 --- a/crates/bashkit/tests/integration/script_execution_tests.rs +++ b/crates/bashkit/tests/integration/script_execution_tests.rs @@ -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")); diff --git a/crates/bashkit/tests/spec_cases/bash/cmd-suggestions.test.sh b/crates/bashkit/tests/spec_cases/bash/cmd-suggestions.test.sh index db7567abb..a941c592a 100644 --- a/crates/bashkit/tests/spec_cases/bash/cmd-suggestions.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/cmd-suggestions.test.sh @@ -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 diff --git a/crates/bashkit/tests/spec_cases/bash/sudo-busybox.test.sh b/crates/bashkit/tests/spec_cases/bash/sudo-busybox.test.sh new file mode 100644 index 000000000..2cd1e6ae2 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/sudo-busybox.test.sh @@ -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 </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 diff --git a/knowledge/log.md b/knowledge/log.md index 5af8d06e8..bf1e36a47 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <