From f86f580cf699890cf13007f2bcab4c9b38a8a0da Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 15:03:08 +0000 Subject: [PATCH 1/3] feat(rootfs): add /proc/self, /proc/1, /proc/uptime and /proc/mounts The shell's own process gets a static synthetic view: status (uid 1000, no capabilities), comm, cmdline, stat, cgroup, mounts, exe -> /bin/bash and fd/0..2. /proc/uptime counts from filesystem build, /proc/mounts lists the single bashkit-vfs root. $BASHPID is set (1, like $$); it was empty. Never host data (TM-INF-003). Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/fs/memory.rs | 22 ++++++ crates/bashkit/src/fs/rootfs.rs | 59 ++++++++++++++-- crates/bashkit/src/interpreter/mod.rs | 6 +- .../integration/limitations_evidence_tests.rs | 2 +- .../integration/snapshot_history_tests.rs | 6 +- .../tests/integration/threat_model_tests.rs | 13 +++- .../tests/spec_cases/bash/proc-self.test.sh | 68 +++++++++++++++++++ knowledge/foundations/vfs.md | 8 ++- knowledge/log.md | 1 + knowledge/security/threat-model.md | 2 +- 10 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/proc-self.test.sh diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index 0a098f45c..4a563e9b9 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -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, link: impl AsRef) { + 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). /// diff --git a/crates/bashkit/src/fs/rootfs.rs b/crates/bashkit/src/fs/rootfs.rs index 2ab70285d..707e80e39 100644 --- a/crates/bashkit/src/fs/rootfs.rs +++ b/crates/bashkit/src/fs/rootfs.rs @@ -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)`. @@ -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. @@ -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!( @@ -225,6 +229,9 @@ 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))), @@ -232,9 +239,45 @@ impl RootFs { 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//*` 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 + Clone) { let set = command_set(names); @@ -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, diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 279ee296a..12ad446fe 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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(); } "!" => { diff --git a/crates/bashkit/tests/integration/limitations_evidence_tests.rs b/crates/bashkit/tests/integration/limitations_evidence_tests.rs index 8bb2304c7..b43589065 100644 --- a/crates/bashkit/tests/integration/limitations_evidence_tests.rs +++ b/crates/bashkit/tests/integration/limitations_evidence_tests.rs @@ -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"); diff --git a/crates/bashkit/tests/integration/snapshot_history_tests.rs b/crates/bashkit/tests/integration/snapshot_history_tests.rs index 42886e8be..7217ee100 100644 --- a/crates/bashkit/tests/integration/snapshot_history_tests.rs +++ b/crates/bashkit/tests/integration/snapshot_history_tests.rs @@ -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, diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index 502db105f..c74cb394a 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -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", ]; diff --git a/crates/bashkit/tests/spec_cases/bash/proc-self.test.sh b/crates/bashkit/tests/spec_cases/bash/proc-self.test.sh new file mode 100644 index 000000000..939fff152 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/proc-self.test.sh @@ -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 diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index cec76154b..692f21b3f 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -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 diff --git a/knowledge/log.md b/knowledge/log.md index 196d2b0cc..7d31581a7 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **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`. * **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x < Date: Wed, 7 Oct 2026 15:35:15 +0000 Subject: [PATCH 2/3] test(cli): /proc completion allows the new synthetic entries Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit-cli/src/interactive.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/bashkit-cli/src/interactive.rs b/crates/bashkit-cli/src/interactive.rs index 37ed910f5..b2d00bb7d 100644 --- a/crates/bashkit-cli/src/interactive.rs +++ b/crates/bashkit-cli/src/interactive.rs @@ -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:?}" ); From 37f874ba94628aee555587d52281a46b7a3faf69 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 15:35:35 +0000 Subject: [PATCH 3/3] test(python): /proc/self/cmdline is the synthetic shell entry Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit-python/tests/_security_core.py | 6 ++++-- knowledge/log.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/bashkit-python/tests/_security_core.py b/crates/bashkit-python/tests/_security_core.py index 8c3338bc6..8ffc9b549 100644 --- a/crates/bashkit-python/tests/_security_core.py +++ b/crates/bashkit-python/tests/_security_core.py @@ -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(): diff --git a/knowledge/log.md b/knowledge/log.md index 7d31581a7..04ace4db7 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,7 +2,6 @@ ## 2026-10-07 -* **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`. * **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x < `/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.