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
6 changes: 5 additions & 1 deletion crates/bashkit-cli/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1184,7 +1184,11 @@ mod tests {
!results.is_empty()
&& results.iter().all(|r| {
let name = r.trim_start_matches("/proc/").trim_end_matches('/');
["cpuinfo", "loadavg", "meminfo", "sys", "version"].contains(&name)
[
"1", "cpuinfo", "loadavg", "meminfo", "mounts", "self", "sys", "uptime",
"version",
]
.contains(&name)
}),
"must not expose host /proc: {results:?}"
);
Expand Down
6 changes: 4 additions & 2 deletions crates/bashkit-python/tests/_security_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ def test_tm_inf_001_vfs_cannot_read_host_etc_passwd():


def test_tm_esc_003_vfs_cannot_read_host_proc():
"""VFS must not expose /proc filesystem."""
"""VFS /proc is synthetic: the shell's own entry, never the host process."""
bash = Bash()
r = bash.execute_sync("cat /proc/self/cmdline")
assert r.exit_code != 0 or r.stdout == "", "Host /proc leaked into VFS"
assert r.stdout == "bash\x00", "Host /proc leaked into VFS"
r = bash.execute_sync("cat /proc/self/environ /proc/self/maps")
assert r.exit_code != 0 and r.stdout == "", "Host /proc leaked into VFS"


def test_tm_iso_002_vfs_writes_are_isolated_between_instances():
Expand Down
22 changes: 22 additions & 0 deletions crates/bashkit/src/fs/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,28 @@ impl InMemoryFs {
);
}

/// Add a symlink whose parent already exists (synchronous, for the
/// read-only system layer). The target is stored, never followed.
pub(crate) fn add_symlink(&self, target: impl AsRef<Path>, link: impl AsRef<Path>) {
let link = Self::normalize_path(link.as_ref());
let target = target.as_ref().to_path_buf();
let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner());
let size = target.as_os_str().len() as u64;
entries.insert(
link,
FsEntry::Symlink {
target,
metadata: Metadata {
file_type: FileType::Symlink,
size,
mode: 0o777,
modified: SystemTime::now(),
created: SystemTime::now(),
},
},
);
}

/// Add a directory, creating parent directories as needed (synchronous,
/// for initial setup).
///
Expand Down
59 changes: 55 additions & 4 deletions crates/bashkit/src/fs/rootfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
//! - `/bin` and `/usr/bin` hold a stub per registered command (not shell-only
//! builtins) so `ls /usr/bin`, `[ -x /usr/bin/env ]`, `which`, and
//! `#!/usr/bin/env` work. Executing a stub dispatches the builtin.
//! - THREAT[TM-INF-003]/[TM-ISO-018]: `/proc` and `/etc` are synthetic; values
//! come from the virtual identity (username, hostname, uid 1000, 4 CPUs) and
//! never from the host. `/etc/passwd` lists only the virtual user and
//! - THREAT[TM-INF-003]/[TM-ISO-018]: `/proc` (including `/proc/self`) and
//! `/etc` are synthetic; values come from the virtual identity (username,
//! hostname, uid 1000, 4 CPUs) and never from the host. `/etc/passwd` lists only the virtual user and
//! `nobody` (no root line), so a `root:x:0:0` match always means a host leak.
//! - Turned off with `BashBuilder::rootfs(false)`.

Expand All @@ -39,6 +39,10 @@ use crate::error::Result;
/// Directory trees served by the system layer.
/// Fresh random UUID per read, as on Linux.
const PROC_UUID: &str = "/proc/sys/kernel/random/uuid";
/// Seconds since the session filesystem was built, like boot time.
const PROC_UPTIME: &str = "/proc/uptime";
/// One root mount, named like `df` reports it.
const PROC_MOUNTS: &str = "bashkit-vfs / bashkit-vfs rw 0 0\n";

const SYS_TREES: &[&str] = &["/etc", "/proc", "/bin", "/usr/bin", "/root", "/dev/zero"];
/// Ancestors of system trees, listed with the system entries merged in.
Expand Down Expand Up @@ -171,7 +175,7 @@ impl RootFs {
sys.add_dir("/root", 0o700);
sys.add_dir("/etc/ssl/certs", 0o755);
let version = env!("CARGO_PKG_VERSION");
let files: [(&str, String); 13] = [
let files: [(&str, String); 15] = [
(
"/etc/os-release",
format!(
Expand Down Expand Up @@ -225,16 +229,55 @@ impl RootFs {
),
),
("/proc/loadavg", "0.00 0.00 0.00 1/1 1\n".to_string()),
// Placeholder; reads return seconds since this filesystem was built.
(PROC_UPTIME, "0.00 0.00\n".to_string()),
("/proc/mounts", PROC_MOUNTS.to_string()),
("/proc/sys/kernel/hostname", format!("{hostname}\n")),
// Placeholder of the right size; reads return a fresh UUID.
(PROC_UUID, format!("{}\n", "0".repeat(36))),
];
for (path, content) in files {
sys.add_file(path, content, 0o644);
}
// The shell's own process: `$$` is 1, so `/proc/self` and `/proc/1`
// hold the same static view (copies, since symlinks are never
// followed, TM-ESC-002).
for dir in ["/proc/self", "/proc/1"] {
for (name, content) in Self::proc_self_files() {
sys.add_file(format!("{dir}/{name}"), content, 0o444);
}
sys.add_symlink("/bin/bash", format!("{dir}/exe"));
sys.add_dir(format!("{dir}/fd"), 0o500);
for (fd, target) in [
("0", "/dev/stdin"),
("1", "/dev/stdout"),
("2", "/dev/stderr"),
] {
sys.add_symlink(target, format!("{dir}/fd/{fd}"));
}
}
sys
}

/// Static `/proc/<pid>/*` content for the virtual shell process.
fn proc_self_files() -> [(&'static str, String); 6] {
[
(
"status",
"Name:\tbash\nUmask:\t0022\nState:\tR (running)\nTgid:\t1\nPid:\t1\nPPid:\t0\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\nThreads:\t1\nCapInh:\t0000000000000000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\nCapBnd:\t0000000000000000\nNoNewPrivs:\t1\nSeccomp:\t2\n"
.to_string(),
),
("comm", "bash\n".to_string()),
("cmdline", "bash\0".to_string()),
("cgroup", "0::/\n".to_string()),
("mounts", PROC_MOUNTS.to_string()),
(
"stat",
"1 (bash) R 0 1 1 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0\n".to_string(),
),
]
}

/// Publish a stub in every bin directory for each command name.
pub(crate) fn set_commands<'a>(&self, names: impl Iterator<Item = &'a str> + Clone) {
let set = command_set(names);
Expand Down Expand Up @@ -269,6 +312,14 @@ impl RootFs {
let uuid = crate::builtins::random::uuid_v4().map_err(std::io::Error::other)?;
return Ok(format!("{uuid}\n").into_bytes());
}
if p == Path::new(PROC_UPTIME) {
let up = SystemTime::now()
.duration_since(self.built)
.unwrap_or_default()
.as_secs_f64();
let idle = up * crate::builtins::VIRTUAL_NPROC as f64;
return Ok(format!("{up:.2} {idle:.2}\n").into_bytes());
}
match self.stub_name(p) {
Some(name) => Ok(stub(&name).into_bytes()),
None => self.sys.read_file(p).await,
Expand Down
6 changes: 4 additions & 2 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11109,8 +11109,10 @@ impl Interpreter {
}
return String::new();
}
"$" => {
// THREAT[TM-INF-014]: Return sandboxed PID, not real host PID.
// THREAT[TM-INF-014]: Return sandboxed PID, not real host PID.
// `$BASHPID` is the same: subshells and jobs report the shell's
// pid too (they have no process of their own).
"$" | "BASHPID" => {
return "1".to_string();
}
"!" => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async fn l_proc_003_no_process_spawning() {
// `/bin/sh` is a root-filesystem stub for the in-process interpreter: it
// sees the VFS, never the host.
let result = bash
.exec("echo vfs > /tmp/f; /bin/sh -c 'cat /tmp/f; [ -e /proc/1/exe ] || echo no-host'")
.exec("echo vfs > /tmp/f; /bin/sh -c 'cat /tmp/f; [ \"$(readlink /proc/1/exe)\" = /bin/bash ] && echo no-host'")
.await
.unwrap();
assert_eq!(result.stdout, "vfs\nno-host\n");
Expand Down
6 changes: 3 additions & 3 deletions crates/bashkit/tests/integration/snapshot_history_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,9 @@ async fn checkout_matches_a_full_snapshot_taken_at_the_same_point() {
let mut via_blob = Bash::new();
via_blob.restore_snapshot(&packed).unwrap();

// Scope to real content: /dev entries are synthetic, and /dev/random
// and /proc/sys/kernel/random/uuid differ per read.
let listing = "find / \\( -path /dev -o -path /proc/sys/kernel/random \\) -prune -o -type f -print | sort | xargs -I{} sh -c 'echo {}; cat {}'";
// Scope to real content: /dev entries are synthetic, and /dev/random,
// /proc/uptime and /proc/sys/kernel/random/uuid differ per read.
let listing = "find / \\( -path /dev -o -path /proc/sys/kernel/random -o -path /proc/uptime \\) -prune -o -type f -print | sort | xargs -I{} sh -c 'echo {}; cat {}'";
assert_eq!(
via_graph.exec(listing).await.unwrap().stdout,
via_blob.exec(listing).await.unwrap().stdout,
Expand Down
13 changes: 11 additions & 2 deletions crates/bashkit/tests/integration/threat_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1016,11 +1016,20 @@ echo "$PRIVATE_KEY"
// These should not expose host information
// /proc and /etc hold only synthetic files (checked below).
let listing = bash.exec("ls /proc").await.unwrap();
assert_eq!(listing.stdout, "cpuinfo\nloadavg\nmeminfo\nsys\nversion\n");
assert_eq!(
listing.stdout,
"1\ncpuinfo\nloadavg\nmeminfo\nmounts\nself\nsys\nuptime\nversion\n"
);
// The shell's own process view is static and synthetic.
let own = bash
.exec("cat /proc/self/cmdline | tr '\\0' ' '; readlink /proc/self/exe; grep ^Uid /proc/1/status")
.await
.unwrap();
assert_eq!(own.stdout, "bash /bin/bash\nUid:\t1000\t1000\t1000\t1000\n");
let probes = vec![
"cat /proc/self/environ 2>/dev/null",
"cat /proc/self/cmdline 2>/dev/null",
"cat /proc/1/environ 2>/dev/null",
"ls /proc/2 2>/dev/null",
"cat /etc/shadow 2>/dev/null",
];

Expand Down
68 changes: 68 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/proc-self.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# /proc/self and friends: synthetic process view of the sandbox shell.

### proc_self_status_identity
### bash_diff: synthetic /proc (pid 1, uid 1000)
grep -E '^(Name|Pid|PPid|Uid):' /proc/self/status
### expect
Name: bash
Pid: 1
PPid: 0
Uid: 1000 1000 1000 1000
### end

### proc_self_comm_and_cmdline
### bash_diff: synthetic /proc
cat /proc/self/comm
tr '\0' ' ' < /proc/self/cmdline; echo
### expect
bash
bash
### end

### proc_pid_matches_self
### bash_diff: synthetic /proc (pid 1)
cmp -s /proc/self/status /proc/$$/status && echo same
### expect
same
### end

### proc_self_exe_and_cgroup
### bash_diff: synthetic /proc
readlink /proc/self/exe
cat /proc/self/cgroup
### expect
/bin/bash
0::/
### end

### proc_mounts_and_uptime
### bash_diff: synthetic /proc
cat /proc/mounts
read up idle < /proc/uptime && [[ $up =~ ^[0-9]+\.[0-9]{2}$ ]] && echo uptime-ok
### expect
bashkit-vfs / bashkit-vfs rw 0 0
uptime-ok
### end

### proc_ls_lists_self
### bash_diff: synthetic /proc
ls /proc | grep -xE 'self|1|uptime|mounts' | sort
### expect
1
mounts
self
uptime
### end

### proc_files_read_only
### bash_diff: synthetic /proc
echo x > /proc/self/comm 2>/dev/null || echo denied
### expect
denied
### end

### bashpid_is_shell_pid
[ "$BASHPID" = "$$" ] && echo same
### expect
same
### end
8 changes: 7 additions & 1 deletion knowledge/foundations/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,13 @@ mounts and custom filesystems always win.
- `/proc`: static `cpuinfo` (`VIRTUAL_NPROC` CPUs), `meminfo`, `version`
(`VIRTUAL_KERNEL_RELEASE`, same as `uname -r`), `loadavg`,
`sys/kernel/hostname`, and `sys/kernel/random/uuid` (a fresh v4 UUID from
the OS CSPRNG on every read, like Linux). No pid directories.
the OS CSPRNG on every read, like Linux), `uptime` (seconds since this
filesystem was built), `mounts` (one `bashkit-vfs` root, as `df` shows).
`self` and `1` (the shell, `$$` = `$BASHPID` = 1) hold identical static
copies: `status` (uid/gid 1000, no capabilities), `comm`, `cmdline`,
`stat`, `cgroup` (`0::/`), `mounts`, `exe` -> `/bin/bash` and
`fd/{0,1,2}` -> `/dev/std*` (symlinks, stored not followed). No
`environ`, no per-job pid directories.
- `/bin`, `/usr/bin`: one virtual stub per registered builtin that is not
shell-only, answered from a shared name set on lookup and never stored as
files (materializing them cost ~2 ms per `Bash` build). A stub's content starts with `STUB_MARKER`; executing it by
Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* **Feature**: `import requests`, `import httpx` and `import httpx2` work in CPython. They are bashkit's own compact implementations of the common API over the HTTP bridge, preloaded in the snapshot (import cost ~0 vs ~3.1 s for vendored upstream requests). http.client read timeouts now surface from `getresponse()`. L-CPY-003 updated, L-CPY-010 added. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#requests-and-httpx-bashkits-own-modules).
* **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.
* **Fix**: `$'\x1e'` and `$'\x1f'` bytes (record/unit separators) are kept as data. They collided with the lexer's quote-boundary markers and were silently dropped, which broke the common `SEP=$'\x1f'` composite-key idiom for associative arrays (`v["$section"$'\x1f'"$key"]`). Decoded ANSI-C content now NUL-escapes those bytes like `$`. Found by gap telemetry (`config_ini_merge` replay printed nothing).
* **Feature**: `/proc/self` and `/proc/1` in the rootfs (static `status`, `comm`, `cmdline`, `stat`, `cgroup`, `mounts`, `exe` -> `/bin/bash`, `fd/0..2`), plus `/proc/uptime` and `/proc/mounts`. `$BASHPID` is set (1, same as `$$`; it was empty). Never host data (TM-INF-003). Step 3 of the level-up roadmap. Spec: `proc-self.test.sh`.
* **Fix**: A usage error in a bundled builtin no longer aborts the script. `grep -E '('`, `grep -P` with a bad pattern, `rg` with a bad regex, an awk syntax error, `grep` with no pattern and `grep -f missing` now fail that one command with exit 2 and the message on stderr, and the script continues (bash semantics; `set -e` still stops). `Err` from custom builtins (`BashBuilder::builtin`, registry, resolver) keeps aborting, per the public contract. See foundations/builtins "Errors: Failed Command vs Aborted Execution".
* **Feature**: CPython `urllib.request`/`http.client` reach the network through bashkit's egress pipeline. New host import `bashkit.http_request` runs each request through `HttpClient` like `curl` (allowlist, SSRF, credential injection, signing, transport, response cap); TLS on the host, no sockets in the guest. The stdlib adapter swaps only http.client's socket for a bridge socket, so the real request/response code runs. Host validates the request record and drops host-owned headers (TM-PY-CPY-012, `cpython_http_fuzz`); `CPythonLimits::max_http_requests` caps requests per call. L-CPY-002 narrowed. Next: `requests` and `httpx2` (pydantic/httpx2, with `import httpx` aliased) in a separate packages crate behind opt-in `cpython-http`. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#http-bridge).
* **Feature**: `gawk`, `mawk` and `nawk` run the awk builtin (same engine, including gawk extensions it already had such as `gensub`, `toupper`). Gap telemetry showed agents probing `gawk` by name. `--version` still identifies as bashkit awk.
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 @@ -436,7 +436,7 @@ execute permission (mode & 0o111); exit 127 missing / 126 non-executable; sheban
|----|--------|--------------|------------|--------|
| TM-INF-001 | Env var leak | `echo $SECRET_KEY` | Env vars caller-controlled | **CALLER RISK** |
| TM-INF-002 | File secrets | `cat /secrets/key` | Virtual FS isolation | **MITIGATED** |
| TM-INF-003 | Proc secrets | `/proc/self/environ` | Only a static synthetic /proc (rootfs: cpuinfo, meminfo, version, loadavg); no `self` or pid dirs | **MITIGATED** |
| TM-INF-003 | Proc secrets | `/proc/self/environ` | Only a static synthetic /proc (rootfs: cpuinfo, meminfo, version, loadavg, uptime, mounts); `self` and `1` are static copies of the virtual shell (uid 1000, no `environ`), never host data | **MITIGATED** |
| TM-INF-004 | Memory dump | Core dumps | No crash dumps | **MITIGATED** |

| TM-INF-013 | Host env leak via jq | jq now uses custom `$__bashkit_env__` variable, not `std::env` |, | **FIXED** (2026-03 audit verified) |
Expand Down
Loading