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
3 changes: 3 additions & 0 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ for sandbox security reasons. See the compliance spec for details.
| `ps` | `-f`, `aux`, `-o`, `-p` | Virtual process list (shell + jobs) |
| `pgrep`/`pkill` | `-f`, `-l`, `-a`, `-c`, `-x`, `-SIGNAL` | Match jobs by command |
| `disown`, `fg`, `bg` | `[JOBSPEC]` | `fg` waits; `bg` is a no-op (jobs already run) |
| `uuidgen` | `-r` | Random (v4) UUID from the OS CSPRNG |
| `openssl` | `rand [-hex\|-base64] [-out FILE] NUM` | Only `rand`; other subcommands fail with `Invalid command` |
| `curl` | `-s`, `-o`, `-X`, `-d`/`--data`, `--data-raw`, `--data-binary`, `--data-urlencode`, `-G`/`--get`, `-H`, `-I`, `-f`, `-L`, `-w`, `--compressed`, `-u`, `-A`, `-e`, `-v`, `-m` | HTTP client (requires http_client feature) |
| `wget` | `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | Download files (requires http_client feature) |
| `time` | `[-p] [-f FORMAT] [-o FILE] [-a] [-v] [--] PIPELINE` | Reserved-word timing; elapsed/status/Bashkit counters are truthful, host CPU/RSS fields say `unavailable` |
Expand Down Expand Up @@ -319,6 +321,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and
| `$-` | ✅ | Current option flags (POSIX) |
| `$_` | ❌ | Last argument |
| `$RANDOM` | ✅ | Random number (0-32767) |
| `$SRANDOM` | ✅ | 32-bit random number from the OS CSPRNG (bash 5.1); assignment ignored |
| `$LINENO` | ✅ | Current line number |

---
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ through configurable limits.
| sleep bypasses timeout (TM-DOS-057) | `sleep N` ignores `ExecutionLimits::timeout` | Host-backed timeout; non-JS wasm blocking sleep is clamped to the execution deadline | **MITIGATED** |
| Interactive terminal flooding (TM-DOS-119) | Unbounded type-ahead, undrained output, or a `vi` buffer grown by pastes | Caps on unread input (1 MiB), retained output (4 MiB), scrollback (1000 rows), transcript (1 MiB), line length, `vi` buffer (8 MiB) and undo history; `:w` goes through VFS limits | MITIGATED |
| Input wait vs. timeout (TM-DOS-120) | Abusing the keyboard-wait exclusion to run work past the deadline | Only time blocked reading the terminal is excluded; CPU work and `sleep` still count | MITIGATED |
| Random generator amplification (TM-DOS-123) | `openssl rand` asked for gigabytes | Requests capped at 1 MiB; fixed-size `uuidgen`/`$SRANDOM` | MITIGATED |
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** |
| Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `<cmd>: <what> limit (<N>) exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** |
| In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** |
Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ mod patch;
mod path;
mod pipeline;
mod printf;
mod random;
mod read;
mod retry;
mod rg;
Expand Down Expand Up @@ -217,6 +218,7 @@ pub use patch::Patch;
pub use path::{Basename, Dirname, Readlink, Realpath};
pub use pipeline::{Tee, Watch, Xargs};
pub use printf::Printf;
pub use random::{Openssl, Uuidgen};
pub use read::Read;
pub use retry::Retry;
pub use rg::Rg;
Expand Down
210 changes: 210 additions & 0 deletions crates/bashkit/src/builtins/random.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! Secret and identifier generators: `uuidgen`, `openssl rand`.
//!
//! Decisions:
//! - Bytes come from the OS CSPRNG via `getrandom` (same source as
//! `/dev/urandom`), never from the `$RANDOM` LCG: these are used for
//! tokens, keys and salts.
//! - `openssl` implements only the `rand` subcommand; anything else fails
//! with openssl's own "Invalid command" message so scripts can fall back.
//! - THREAT[TM-DOS-123]: `openssl rand N` is capped at
//! `OPENSSL_RAND_MAX_BYTES` so one call cannot allocate unbounded memory.
//! - `uuidgen` emits random (v4) UUIDs; time-based `-t` and name-based
//! `-m`/`-s` are not supported (no host clock/MAC leakage, L-RAND-001).

use async_trait::async_trait;
use base64::Engine;

use super::{Builtin, Context, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;

/// Largest `openssl rand` request (1 MiB).
pub(crate) const OPENSSL_RAND_MAX_BYTES: usize = 1024 * 1024;

fn random_bytes(n: usize) -> std::result::Result<Vec<u8>, String> {
let mut buf = vec![0u8; n];
getrandom::fill(&mut buf).map_err(|_| "random source unavailable".to_string())?;
Ok(buf)
}

/// A random version 4 UUID in lowercase hyphenated form.
pub(crate) fn uuid_v4() -> std::result::Result<String, String> {
let mut b = random_bytes(16)?;
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
Ok(format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..32]
))
}

/// The `uuidgen` builtin: `uuidgen [-r|--random]`.
pub struct Uuidgen;

#[async_trait]
impl Builtin for Uuidgen {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
for arg in ctx.args {
match arg.as_str() {
"-r" | "--random" => {}
"-h" | "--help" => {
return Ok(ExecResult::ok(
"Usage: uuidgen [-r|--random]\nCreate a new random (version 4) UUID.\n",
));
}
"-t" | "--time" | "-m" | "--md5" | "-s" | "--sha1" => {
return Ok(ExecResult::err(
format!("uuidgen: {arg}: not supported in bashkit (random UUIDs only)\n"),
1,
));
}
other => {
return Ok(ExecResult::err(
format!(
"uuidgen: invalid option -- '{}'\n",
other.trim_start_matches('-')
),
1,
));
}
}
}
match uuid_v4() {
Ok(u) => Ok(ExecResult::ok(format!("{u}\n"))),
Err(e) => Ok(ExecResult::err(format!("uuidgen: {e}\n"), 1)),
}
}
}

/// The `openssl` builtin: only `openssl rand [-hex|-base64] [-out FILE] NUM`.
pub struct Openssl;

const RAND_USAGE: &str = "rand: Use -help for summary.\n";

#[async_trait]
impl Builtin for Openssl {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let Some((cmd, rest)) = ctx.args.split_first() else {
return Ok(ExecResult::err(
"openssl: interactive mode is not supported in bashkit\n",
1,
));
};
if cmd != "rand" {
return Ok(ExecResult::err(
format!("Invalid command '{cmd}'; type \"help\" for a list.\n"),
1,
));
}
let mut hex = false;
let mut b64 = false;
let mut out: Option<&str> = None;
let mut num: Option<&str> = None;
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"-hex" => hex = true,
"-base64" => b64 = true,
"-out" => {
i += 1;
match rest.get(i) {
Some(f) => out = Some(f),
None => return Ok(ExecResult::err(RAND_USAGE, 1)),
}
}
"-help" => {
return Ok(ExecResult::ok(
"Usage: rand [options] num\n -out outfile Output file\n -base64 Base64 encode output\n -hex Hex encode output\n",
));
}
a if num.is_none() && !a.starts_with('-') => num = Some(a),
a => {
return Ok(ExecResult::err(
format!("rand: Unknown option or number: {a}\n{RAND_USAGE}"),
1,
));
}
}
i += 1;
}
let Some(num) = num else {
return Ok(ExecResult::err(RAND_USAGE, 1));
};
let n: usize = match num.parse() {
Ok(n) => n,
Err(_) => {
return Ok(ExecResult::err(
format!("rand: Can't parse \"{num}\" as a number\n{RAND_USAGE}"),
1,
));
}
};
if n > OPENSSL_RAND_MAX_BYTES {
return Ok(ExecResult::err(
format!("rand: {n} bytes exceeds the {OPENSSL_RAND_MAX_BYTES} byte limit\n"),
1,
));
}
let bytes = match random_bytes(n) {
Ok(b) => b,
Err(e) => return Ok(ExecResult::err(format!("rand: {e}\n"), 1)),
};
let data: Vec<u8> = if n == 0 {
Vec::new()
} else if hex {
let mut s: String = bytes.iter().map(|x| format!("{x:02x}")).collect();
s.push('\n');
s.into_bytes()
} else if b64 {
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
// openssl wraps base64 at 64 columns.
let mut s = String::with_capacity(enc.len() + enc.len() / 64 + 1);
for chunk in enc.as_bytes().chunks(64) {
s.push_str(&String::from_utf8_lossy(chunk));
s.push('\n');
}
s.into_bytes()
} else {
bytes
};
match out {
Some(f) => {
let path = resolve_path(ctx.cwd, f);
if let Err(e) = ctx.fs.write_file(&path, &data).await {
return Ok(ExecResult::err(format!("rand: {f}: {e}\n"), 1));
}
Ok(ExecResult::ok(String::new()))
}
None => Ok(ExecResult::ok(crate::StreamData::from(data))),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn uuid_v4_shape() {
let u = uuid_v4().unwrap();
assert_eq!(u.len(), 36);
assert_eq!(&u[14..15], "4");
assert!(matches!(&u[19..20], "8" | "9" | "a" | "b"));
assert_ne!(u, uuid_v4().unwrap());
}

#[tokio::test]
async fn openssl_rand_limits() {
let mut bash = crate::Bash::new();
let r = bash.exec("openssl rand -hex 2000000").await.unwrap();
assert_eq!(r.exit_code, 1);
assert!(r.stderr.contains("limit"));
let r = bash.exec("uuidgen -t").await.unwrap();
assert_eq!(r.exit_code, 1);
}
}
15 changes: 15 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,8 @@ impl Interpreter {
"readlink" => Readlink,
"mkdir" => Mkdir,
"mktemp" => Mktemp,
"uuidgen" => Uuidgen,
"openssl" => Openssl,
"mkfifo" => Mkfifo,
"rm" => Rm,
"cp" => Cp,
Expand Down Expand Up @@ -9677,6 +9679,10 @@ impl Interpreter {
};
let resolved: &str = resolved_string.as_str();
// RANDOM=N reseeds the PRNG (matches bash behavior)
// SRANDOM ignores assignment (bash 5.1).
if resolved == "SRANDOM" {
return;
}
if resolved == "RANDOM" {
self.random_state
.store(value.parse::<u32>().unwrap_or(0), Ordering::Relaxed);
Expand Down Expand Up @@ -10377,6 +10383,14 @@ impl Interpreter {
self.random_state.store(next, Ordering::Relaxed);
return ((next >> 16) & 0x7fff).to_string();
}
"SRANDOM" => {
// $SRANDOM - 32 bits from the OS CSPRNG, not the LCG (bash 5.1).
let mut b = [0u8; 4];
if getrandom::fill(&mut b).is_err() {
return String::new();
}
return u32::from_le_bytes(b).to_string();
}
"LINENO" => {
// $LINENO - current line number from command span
return self.current_line.to_string();
Expand Down Expand Up @@ -10448,6 +10462,7 @@ impl Interpreter {
| "!"
| "-"
| "RANDOM"
| "SRANDOM"
| "LINENO"
| "PWD"
| "OLDPWD"
Expand Down
82 changes: 82 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/random.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
### srandom_is_32bit_number
# $SRANDOM (bash 5.1+) is a fresh 32-bit value from the system CSPRNG.
[[ $SRANDOM =~ ^[0-9]+$ ]] && echo num
(( SRANDOM <= 4294967295 )) && echo fits
[ "$SRANDOM" != "$SRANDOM" ] && echo differs
### expect
num
fits
differs
### end

### srandom_assignment_ignored
SRANDOM=5
[ "$SRANDOM" != 5 ] && echo ignored
### expect
ignored
### end

### openssl_rand_hex
openssl rand -hex 16 | grep -cE '^[0-9a-f]{32}$'
### expect
1
### end

### openssl_rand_base64_and_raw
openssl rand -base64 12 | grep -cE '^[A-Za-z0-9+/]{16}$'
openssl rand 7 | wc -c
openssl rand -hex 0 | wc -c
### expect
1
7
0
### end

### openssl_rand_out_file
openssl rand -out /tmp/key.hex -hex 3
grep -cE '^[0-9a-f]{6}$' /tmp/key.hex
### expect
1
### end

### openssl_rand_bad_count
openssl rand -hex abc
echo rc=$?
### expect
rc=1
### end

### openssl_unknown_command
openssl nope 2>/dev/null
echo rc=$?
### expect
rc=1
### end

### uuidgen_random_v4
### bash_diff: uuidgen is not installed on every host
uuidgen | grep -cE '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
uuidgen -r | wc -c
[ "$(uuidgen)" != "$(uuidgen)" ] && echo unique
### expect
1
37
unique
### end

### password_idioms
# Common secret-generation one-liners.
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 20 | wc -c
head -c 32 /dev/urandom | base64 | tr -d '\n' | wc -c
### expect
20
44
### end

### uuidgen_time_based_unsupported
### bash_diff: L-RAND-001, only random (v4) UUIDs
uuidgen -t 2>/dev/null
echo rc=$?
### expect
rc=1
### 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**: Secret generators. `$SRANDOM` (bash 5.1, 32 bits from `getrandom`), `uuidgen` (v4 only, L-RAND-001) and `openssl rand [-hex|-base64] [-out] N` (only `rand`; capped at 1 MiB, TM-DOS-123). All draw from the OS CSPRNG like `/dev/urandom`, never the `$RANDOM` LCG.
* **Decision**: CPython stdlib ships as bytecode only. Non-preloaded imports compiled source on Pulley every call (3-7 s each); unchecked-hash `.pyc` cut that ~10x. Unusable modules (`ctypes`, `ssl`, network clients/servers, `pdb`, `pydoc`, `_pyrepl`) and pure-Python C twins dropped so the crate stays under the 10 MiB cap. Dropping preloaded modules was rejected: ~1-2 ms first-call gain, seconds per import lost. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md), [Limitations](operations/limitations.md) L-CPY-009.
* **Performance**: Profiled CPython start. A warm `python3 -c 'print(1)'` was ~4.5 ms of Pulley interpreting ~500K guest instructions plus ~1.5 ms in ~340 copy-on-write faults; the first call adds ~13 ms of one-time memory-image build (the 40 MB snapshot span copied into a memfd). Snapshot objects are now immortal (no refcount writes, faults -20%) and the per-call environment is installed from C; warm calls went from ~6.2 to ~5.1 ms. Native AOT instead of Pulley measured 2.3 ms per call but needs executable memory and a 41 MB copy on first load; not taken yet. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md).

Expand Down
1 change: 1 addition & 0 deletions knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance`
| L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution (fd redirects work) | True process replace would break sandbox containment | TM-ESC-005 |
| L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` |
| L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` |
| L-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` 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` |
| L-FS-002 | No file permission enforcement in the VFS | Single-tenant virtual FS; permissions would be theater | `l_fs_002_no_permission_enforcement` |
Expand Down
Loading
Loading