Skip to content

Commit 22dc42e

Browse files
committed
feat(builtins): $SRANDOM, uuidgen, openssl rand
Secret generators backed by the OS CSPRNG (same source as /dev/urandom). openssl implements only rand, capped at 1 MiB (TM-DOS-123); uuidgen emits v4 only (L-RAND-001). Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA
1 parent 272054d commit 22dc42e

10 files changed

Lines changed: 325 additions & 1 deletion

File tree

‎crates/bashkit/docs/compatibility.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ for sandbox security reasons. See the compliance spec for details.
9797
| `ps` | `-f`, `aux`, `-o`, `-p` | Virtual process list (shell + jobs) |
9898
| `pgrep`/`pkill` | `-f`, `-l`, `-a`, `-c`, `-x`, `-SIGNAL` | Match jobs by command |
9999
| `disown`, `fg`, `bg` | `[JOBSPEC]` | `fg` waits; `bg` is a no-op (jobs already run) |
100+
| `uuidgen` | `-r` | Random (v4) UUID from the OS CSPRNG |
101+
| `openssl` | `rand [-hex\|-base64] [-out FILE] NUM` | Only `rand`; other subcommands fail with `Invalid command` |
100102
| `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) |
101103
| `wget` | `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | Download files (requires http_client feature) |
102104
| `time` | `[-p] [-f FORMAT] [-o FILE] [-a] [-v] [--] PIPELINE` | Reserved-word timing; elapsed/status/Bashkit counters are truthful, host CPU/RSS fields say `unavailable` |
@@ -319,6 +321,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and
319321
| `$-` | ✅ | Current option flags (POSIX) |
320322
| `$_` | ❌ | Last argument |
321323
| `$RANDOM` | ✅ | Random number (0-32767) |
324+
| `$SRANDOM` | ✅ | 32-bit random number from the OS CSPRNG (bash 5.1); assignment ignored |
322325
| `$LINENO` | ✅ | Current line number |
323326

324327
---

‎crates/bashkit/docs/threat-model.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ through configurable limits.
114114
| 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** |
115115
| 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 |
116116
| 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 |
117+
| Random generator amplification (TM-DOS-123) | `openssl rand` asked for gigabytes | Requests capped at 1 MiB; fixed-size `uuidgen`/`$SRANDOM` | MITIGATED |
117118
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** |
118119
| 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** |
119120
| 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** |

‎crates/bashkit/src/builtins/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ mod patch;
9393
mod path;
9494
mod pipeline;
9595
mod printf;
96+
mod random;
9697
mod read;
9798
mod retry;
9899
mod rg;
@@ -217,6 +218,7 @@ pub use patch::Patch;
217218
pub use path::{Basename, Dirname, Readlink, Realpath};
218219
pub use pipeline::{Tee, Watch, Xargs};
219220
pub use printf::Printf;
221+
pub use random::{Openssl, Uuidgen};
220222
pub use read::Read;
221223
pub use retry::Retry;
222224
pub use rg::Rg;
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
//! Secret and identifier generators: `uuidgen`, `openssl rand`.
2+
//!
3+
//! Decisions:
4+
//! - Bytes come from the OS CSPRNG via `getrandom` (same source as
5+
//! `/dev/urandom`), never from the `$RANDOM` LCG: these are used for
6+
//! tokens, keys and salts.
7+
//! - `openssl` implements only the `rand` subcommand; anything else fails
8+
//! with openssl's own "Invalid command" message so scripts can fall back.
9+
//! - THREAT[TM-DOS-123]: `openssl rand N` is capped at
10+
//! `OPENSSL_RAND_MAX_BYTES` so one call cannot allocate unbounded memory.
11+
//! - `uuidgen` emits random (v4) UUIDs; time-based `-t` and name-based
12+
//! `-m`/`-s` are not supported (no host clock/MAC leakage, L-RAND-001).
13+
14+
use async_trait::async_trait;
15+
use base64::Engine;
16+
17+
use super::{Builtin, Context, resolve_path};
18+
use crate::error::Result;
19+
use crate::interpreter::ExecResult;
20+
21+
/// Largest `openssl rand` request (1 MiB).
22+
pub(crate) const OPENSSL_RAND_MAX_BYTES: usize = 1024 * 1024;
23+
24+
fn random_bytes(n: usize) -> std::result::Result<Vec<u8>, String> {
25+
let mut buf = vec![0u8; n];
26+
getrandom::fill(&mut buf).map_err(|_| "random source unavailable".to_string())?;
27+
Ok(buf)
28+
}
29+
30+
/// A random version 4 UUID in lowercase hyphenated form.
31+
pub(crate) fn uuid_v4() -> std::result::Result<String, String> {
32+
let mut b = random_bytes(16)?;
33+
b[6] = (b[6] & 0x0f) | 0x40;
34+
b[8] = (b[8] & 0x3f) | 0x80;
35+
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
36+
Ok(format!(
37+
"{}-{}-{}-{}-{}",
38+
&h[0..8],
39+
&h[8..12],
40+
&h[12..16],
41+
&h[16..20],
42+
&h[20..32]
43+
))
44+
}
45+
46+
/// The `uuidgen` builtin: `uuidgen [-r|--random]`.
47+
pub struct Uuidgen;
48+
49+
#[async_trait]
50+
impl Builtin for Uuidgen {
51+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
52+
for arg in ctx.args {
53+
match arg.as_str() {
54+
"-r" | "--random" => {}
55+
"-h" | "--help" => {
56+
return Ok(ExecResult::ok(
57+
"Usage: uuidgen [-r|--random]\nCreate a new random (version 4) UUID.\n",
58+
));
59+
}
60+
"-t" | "--time" | "-m" | "--md5" | "-s" | "--sha1" => {
61+
return Ok(ExecResult::err(
62+
format!("uuidgen: {arg}: not supported in bashkit (random UUIDs only)\n"),
63+
1,
64+
));
65+
}
66+
other => {
67+
return Ok(ExecResult::err(
68+
format!(
69+
"uuidgen: invalid option -- '{}'\n",
70+
other.trim_start_matches('-')
71+
),
72+
1,
73+
));
74+
}
75+
}
76+
}
77+
match uuid_v4() {
78+
Ok(u) => Ok(ExecResult::ok(format!("{u}\n"))),
79+
Err(e) => Ok(ExecResult::err(format!("uuidgen: {e}\n"), 1)),
80+
}
81+
}
82+
}
83+
84+
/// The `openssl` builtin: only `openssl rand [-hex|-base64] [-out FILE] NUM`.
85+
pub struct Openssl;
86+
87+
const RAND_USAGE: &str = "rand: Use -help for summary.\n";
88+
89+
#[async_trait]
90+
impl Builtin for Openssl {
91+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
92+
let Some((cmd, rest)) = ctx.args.split_first() else {
93+
return Ok(ExecResult::err(
94+
"openssl: interactive mode is not supported in bashkit\n",
95+
1,
96+
));
97+
};
98+
if cmd != "rand" {
99+
return Ok(ExecResult::err(
100+
format!("Invalid command '{cmd}'; type \"help\" for a list.\n"),
101+
1,
102+
));
103+
}
104+
let mut hex = false;
105+
let mut b64 = false;
106+
let mut out: Option<&str> = None;
107+
let mut num: Option<&str> = None;
108+
let mut i = 0;
109+
while i < rest.len() {
110+
match rest[i].as_str() {
111+
"-hex" => hex = true,
112+
"-base64" => b64 = true,
113+
"-out" => {
114+
i += 1;
115+
match rest.get(i) {
116+
Some(f) => out = Some(f),
117+
None => return Ok(ExecResult::err(RAND_USAGE, 1)),
118+
}
119+
}
120+
"-help" => {
121+
return Ok(ExecResult::ok(
122+
"Usage: rand [options] num\n -out outfile Output file\n -base64 Base64 encode output\n -hex Hex encode output\n",
123+
));
124+
}
125+
a if num.is_none() && !a.starts_with('-') => num = Some(a),
126+
a => {
127+
return Ok(ExecResult::err(
128+
format!("rand: Unknown option or number: {a}\n{RAND_USAGE}"),
129+
1,
130+
));
131+
}
132+
}
133+
i += 1;
134+
}
135+
let Some(num) = num else {
136+
return Ok(ExecResult::err(RAND_USAGE, 1));
137+
};
138+
let n: usize = match num.parse() {
139+
Ok(n) => n,
140+
Err(_) => {
141+
return Ok(ExecResult::err(
142+
format!("rand: Can't parse \"{num}\" as a number\n{RAND_USAGE}"),
143+
1,
144+
));
145+
}
146+
};
147+
if n > OPENSSL_RAND_MAX_BYTES {
148+
return Ok(ExecResult::err(
149+
format!("rand: {n} bytes exceeds the {OPENSSL_RAND_MAX_BYTES} byte limit\n"),
150+
1,
151+
));
152+
}
153+
let bytes = match random_bytes(n) {
154+
Ok(b) => b,
155+
Err(e) => return Ok(ExecResult::err(format!("rand: {e}\n"), 1)),
156+
};
157+
let data: Vec<u8> = if n == 0 {
158+
Vec::new()
159+
} else if hex {
160+
let mut s: String = bytes.iter().map(|x| format!("{x:02x}")).collect();
161+
s.push('\n');
162+
s.into_bytes()
163+
} else if b64 {
164+
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
165+
// openssl wraps base64 at 64 columns.
166+
let mut s = String::with_capacity(enc.len() + enc.len() / 64 + 1);
167+
for chunk in enc.as_bytes().chunks(64) {
168+
s.push_str(&String::from_utf8_lossy(chunk));
169+
s.push('\n');
170+
}
171+
s.into_bytes()
172+
} else {
173+
bytes
174+
};
175+
match out {
176+
Some(f) => {
177+
let path = resolve_path(ctx.cwd, f);
178+
if let Err(e) = ctx.fs.write_file(&path, &data).await {
179+
return Ok(ExecResult::err(format!("rand: {f}: {e}\n"), 1));
180+
}
181+
Ok(ExecResult::ok(String::new()))
182+
}
183+
None => Ok(ExecResult::ok(crate::StreamData::from(data))),
184+
}
185+
}
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
192+
#[test]
193+
fn uuid_v4_shape() {
194+
let u = uuid_v4().unwrap();
195+
assert_eq!(u.len(), 36);
196+
assert_eq!(&u[14..15], "4");
197+
assert!(matches!(&u[19..20], "8" | "9" | "a" | "b"));
198+
assert_ne!(u, uuid_v4().unwrap());
199+
}
200+
201+
#[tokio::test]
202+
async fn openssl_rand_limits() {
203+
let mut bash = crate::Bash::new();
204+
let r = bash.exec("openssl rand -hex 2000000").await.unwrap();
205+
assert_eq!(r.exit_code, 1);
206+
assert!(r.stderr.contains("limit"));
207+
let r = bash.exec("uuidgen -t").await.unwrap();
208+
assert_eq!(r.exit_code, 1);
209+
}
210+
}

‎crates/bashkit/src/interpreter/mod.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,8 @@ impl Interpreter {
15121512
"readlink" => Readlink,
15131513
"mkdir" => Mkdir,
15141514
"mktemp" => Mktemp,
1515+
"uuidgen" => Uuidgen,
1516+
"openssl" => Openssl,
15151517
"mkfifo" => Mkfifo,
15161518
"rm" => Rm,
15171519
"cp" => Cp,
@@ -9677,6 +9679,10 @@ impl Interpreter {
96779679
};
96789680
let resolved: &str = resolved_string.as_str();
96799681
// RANDOM=N reseeds the PRNG (matches bash behavior)
9682+
// SRANDOM ignores assignment (bash 5.1).
9683+
if resolved == "SRANDOM" {
9684+
return;
9685+
}
96809686
if resolved == "RANDOM" {
96819687
self.random_state
96829688
.store(value.parse::<u32>().unwrap_or(0), Ordering::Relaxed);
@@ -10377,6 +10383,14 @@ impl Interpreter {
1037710383
self.random_state.store(next, Ordering::Relaxed);
1037810384
return ((next >> 16) & 0x7fff).to_string();
1037910385
}
10386+
"SRANDOM" => {
10387+
// $SRANDOM - 32 bits from the OS CSPRNG, not the LCG (bash 5.1).
10388+
let mut b = [0u8; 4];
10389+
if getrandom::fill(&mut b).is_err() {
10390+
return String::new();
10391+
}
10392+
return u32::from_le_bytes(b).to_string();
10393+
}
1038010394
"LINENO" => {
1038110395
// $LINENO - current line number from command span
1038210396
return self.current_line.to_string();
@@ -10448,6 +10462,7 @@ impl Interpreter {
1044810462
| "!"
1044910463
| "-"
1045010464
| "RANDOM"
10465+
| "SRANDOM"
1045110466
| "LINENO"
1045210467
| "PWD"
1045310468
| "OLDPWD"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
### srandom_is_32bit_number
2+
# $SRANDOM (bash 5.1+) is a fresh 32-bit value from the system CSPRNG.
3+
[[ $SRANDOM =~ ^[0-9]+$ ]] && echo num
4+
(( SRANDOM <= 4294967295 )) && echo fits
5+
[ "$SRANDOM" != "$SRANDOM" ] && echo differs
6+
### expect
7+
num
8+
fits
9+
differs
10+
### end
11+
12+
### srandom_assignment_ignored
13+
SRANDOM=5
14+
[ "$SRANDOM" != 5 ] && echo ignored
15+
### expect
16+
ignored
17+
### end
18+
19+
### openssl_rand_hex
20+
openssl rand -hex 16 | grep -cE '^[0-9a-f]{32}$'
21+
### expect
22+
1
23+
### end
24+
25+
### openssl_rand_base64_and_raw
26+
openssl rand -base64 12 | grep -cE '^[A-Za-z0-9+/]{16}$'
27+
openssl rand 7 | wc -c
28+
openssl rand -hex 0 | wc -c
29+
### expect
30+
1
31+
7
32+
0
33+
### end
34+
35+
### openssl_rand_out_file
36+
openssl rand -out /tmp/key.hex -hex 3
37+
grep -cE '^[0-9a-f]{6}$' /tmp/key.hex
38+
### expect
39+
1
40+
### end
41+
42+
### openssl_rand_bad_count
43+
openssl rand -hex abc
44+
echo rc=$?
45+
### expect
46+
rc=1
47+
### end
48+
49+
### openssl_unknown_command
50+
openssl nope 2>/dev/null
51+
echo rc=$?
52+
### expect
53+
rc=1
54+
### end
55+
56+
### uuidgen_random_v4
57+
### bash_diff: uuidgen is not installed on every host
58+
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}$'
59+
uuidgen -r | wc -c
60+
[ "$(uuidgen)" != "$(uuidgen)" ] && echo unique
61+
### expect
62+
1
63+
37
64+
unique
65+
### end
66+
67+
### password_idioms
68+
# Common secret-generation one-liners.
69+
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 20 | wc -c
70+
head -c 32 /dev/urandom | base64 | tr -d '\n' | wc -c
71+
### expect
72+
20
73+
44
74+
### end
75+
76+
### uuidgen_time_based_unsupported
77+
### bash_diff: L-RAND-001, only random (v4) UUIDs
78+
uuidgen -t 2>/dev/null
79+
echo rc=$?
80+
### expect
81+
rc=1
82+
### end

‎knowledge/log.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2026-10-07
44

5+
* **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.
56
* **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.
67
* **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).
78

‎knowledge/operations/limitations.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance`
4545
| 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 |
4646
| 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` |
4747
| 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` |
48+
| 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` |
4849
| 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` |
4950
| 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` |
5051
| L-FS-002 | No file permission enforcement in the VFS | Single-tenant virtual FS; permissions would be theater | `l_fs_002_no_permission_enforcement` |

0 commit comments

Comments
 (0)