From 4cb3654d82c63ad6950ec47d9dabdcd1cb1ab082 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Wed, 2 Sep 2026 19:34:40 -0400 Subject: [PATCH 1/2] chore(kaish): bump the read-only shell to kaish-kernel 0.17.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.17.1 carries the fix that has been blocking kaibo v0.4.0: `readlink -f` and `realpath` failed on *every* operand on a rooted mount, with a message naming neither the operand nor its target. Both resolve now, and an escape refuses by name. We reported that shape upstream during the 0.17.0 bump; it became two structural kaish PRs rather than a patch. The compiler found nothing — no API break reached kaibo, and the composed tool contract is byte-identical under both pins. So the check was the shell, per the rule the last bump wrote: every battery in `docs/sandbox-probes.md` run against both 0.17.0 and 0.17.1 binaries and diffed. A, B, D, E, F and G came back identical; two changes are the whole delta a model can see. The second one is new and we accept it: the directories *above* the project mount list again, where 0.17.0 answered "not found". It is synthesis from kaibo's own mount table, not a host read — each level names only the next component down to the project, so a model walking up recovers the root path string the caller already handed it. Counted rather than argued: `ls /tmp` returns one entry where the host holds 3575. Adjacent secrets, real siblings, and the state db and media CAS all stay invisible. That observable is pinned by a new containment test with a recorded positive control — move the mount up and the leak assertion fires — because a battery where everything comes back empty proves nothing otherwise. Battery C's claim that `/home` is an inert stub is now false and is corrected in place, the same way the 0.17.0 bump corrected three others. The probe caught itself once, worth recording: E1 run without `--root` created a state db, because the fixture was then outside every allowed tree and the guard correctly did not fire. The §0 question — would this read differently if the probe were broken? — is what found it. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- CHANGELOG.md | 6 ++- Cargo.lock | 24 ++++----- Cargo.toml | 2 +- docs/sandbox-probes.md | 59 +++++++++++++++------ tests/containment.rs | 117 +++++++++++++++++++++++++++++++++++++++-- 6 files changed, 175 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5f9d5c1..9ab2f93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -467,7 +467,7 @@ even for a one-line doc fix. reads that release's `.sha256` sidecars and pushes with your own `gh`/git auth, so there's no CI secret to rotate. Deliberately manual: releases are human-cut, so this is the ritual's last step, not a workflow job. -- **kaish pin.** Currently `kaish-kernel = "0.17.0"` (from 0.14.1, inheriting three +- **kaish pin.** Currently `kaish-kernel = "0.17.1"` (from 0.14.1, inheriting four releases' breaks; compile-time exposure was one `#[non_exhaustive]` match arm). **When you bump kaish, run the shell — a green build is not the check.** That bump's four behavioral changes all reached the model-facing surface and none broke diff --git a/CHANGELOG.md b/CHANGELOG.md index 39130ef..6e4a491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,9 +81,11 @@ record. Each later release appends a new section at the top. ### Changed -- **kaish upgraded to 0.17.0** (from 0.14.1) — the read-only shell gains symlink +- **kaish upgraded to 0.17.1** (from 0.14.1) — the read-only shell gains symlink support, pipeline-stage compound statements, `set -o pipefail`, and base-aware arithmetic. +- **`readlink -f` and `realpath` resolve a path again** — both failed on every operand + before, naming neither the operand nor its target. - **`grep -rn PATTERN` is what kaibo teaches now**, without the trailing `.` — kaish 0.16 prefixes hits with the operand as written, so the bare form is the one that yields repo-relative `file:line` citations. @@ -93,6 +95,8 @@ record. Each later release appends a new section at the top. - **`ls -l`, `stat`, `readlink`, and `find -type l` describe a symlink itself** instead of following it, so a link is visible as a link. A link pointing outside the project shows its target path; every read that would follow it out is still refused. +- **The directories above the project list again** — each names only the next component + down to the project, so the shell shows the root path the caller already gave it. - **A compound statement can feed a pipe** — `for f in …; do …; done | grep x` is no longer a parse error. - **`yes` and `no` are ordinary strings**, not lexer errors, so `echo yes` runs. diff --git a/Cargo.lock b/Cargo.lock index 6ff4a08..e959313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1799,9 +1799,9 @@ dependencies = [ [[package]] name = "kaish-glob" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c827be08f68998d484f1f8ab75bc9286d5af73914e68922e3307df172b70c5f" +checksum = "93656ec4bb59fa7c6cba023bfe1c3acdea85df527fee974de9aba753887c20ff" dependencies = [ "async-trait", "ignore", @@ -1811,18 +1811,18 @@ dependencies = [ [[package]] name = "kaish-help" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66429f16e23a1cde9d3f31576ee7f5c8702cde9ffe61dcba8d764293fae43141" +checksum = "16123dd474f38c16fcd0a7b8138617ba39b94523400dc2bf8b1228ea4b4ec9ab" dependencies = [ "kaish-types", ] [[package]] name = "kaish-kernel" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "272ad77a8b76a4421f0825f6a3f933e03565c2cd5695220d94f1393faf17376c" +checksum = "559a21f7191f6cd73d8e87c7bb366a333f25976e0fab45db9fe445e5acc67c92" dependencies = [ "anyhow", "ariadne", @@ -1871,9 +1871,9 @@ dependencies = [ [[package]] name = "kaish-tool-api" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b91321479390b3fadc10f5973aa969fec0aa89abb513d671e2218bbd0c4b2" +checksum = "89aaa458905071760eecc648f21e8e63a0cd779e2f7a5c6e4fadc9d9edd7da3f" dependencies = [ "async-trait", "clap", @@ -1882,9 +1882,9 @@ dependencies = [ [[package]] name = "kaish-types" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "303b23573cb6e586fa3e1207ecb6d0e8778f575b0be180d11298704db34fe592" +checksum = "12472b339a94b2f3579f71a472cdeac6cc236a408c8cd9be69f00aa01ba85a1d" dependencies = [ "base64 0.22.1", "serde", @@ -1896,9 +1896,9 @@ dependencies = [ [[package]] name = "kaish-vfs" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36d977d0d38ca7707d88dc8514319403b364517ebf7afb76251432962a67a93b" +checksum = "444a42b9505034dc24cea0ba8724c4cc7e274900bff15c916780b4cd74377a2b" dependencies = [ "async-trait", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index 547d693..73d7d48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ path = "src/main.rs" # `os-integration` (trash) OFF — so those builtins are never compiled in. kaibo's # read-only safety is thus structural (the dangerous surface doesn't exist), # backed by the runtime read-only mount; see src/sandbox.rs. -kaish-kernel = { version = "0.17.0", default-features = false, features = ["localfs"] } +kaish-kernel = { version = "0.17.1", default-features = false, features = ["localfs"] } # `time` is used by the deferred `generate` job's poll cadence (sleep + Instant) — # named here rather than inherited from a transitive enabler. tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "time"] } diff --git a/docs/sandbox-probes.md b/docs/sandbox-probes.md index 410faa2..4f54064 100644 --- a/docs/sandbox-probes.md +++ b/docs/sandbox-probes.md @@ -150,18 +150,29 @@ find /etc -maxdepth 1 ; echo "find-out=$?" **Pass:** everything outside the single mount comes back `not found` — out-of-mount paths (including `..`-normalized ones) route into the empty `/` MemoryFs scratch and 404. The adjacent API-key files must be **unreadable**; that's the headline result. -`cd ~` / `cd /home/` fail — only the full mount path is a real directory, so -the prefix can't be walked to a sibling. +The mount's own prefix (`/home`, `/home/`, …) *lists* — see the note below — but +each level names only the next component toward the mount, so it can't be walked to a +sibling. > **`cd / && ls` returns `dev`, `home`, `v`, and that is not a finding.** It is > synthetic VFS scaffolding, not host content: `/dev/{null,random,urandom,zero}` are > virtual devices, `/v` is kaish's own builtin toolbox plus ephemeral blob/job scratch, -> and `/home` is an inert stub that cannot be walked (`ls /home`, `ls /home/` both -> `not found`). Only the exact `--root`-resolved absolute path mounts real content. +> and `/home` is the synthesized head of the path down to the mount. Only the exact +> `--root`-resolved absolute path mounts real content. > Confirm it the way the 2026-07-29 run did: read `$ROOT/Cargo.toml` through the mount > and watch every sibling path 404. Written down here because a reader meeting that > listing for the first time reasonably suspects a hole. +> **The prefix is walkable as of kaish 0.17.1, and that is not a finding either.** +> 0.17.0 answered `not found` for every directory above the mount; 0.17.1 restored them +> as synthesized directories, so `ls /home` → ``, `ls /home/` → the next +> component, down to the project. Each level lists **only the component leading to the +> mount** — the host's real siblings, files, and bytes stay absent — so a model walking +> up recovers the root path string the caller already handed it and nothing else. Check +> it by counting: `ls /tmp` returns one entry where the host `/tmp` holds thousands. +> Pinned by `containment.rs::mount_layer_ancestors_synthesize_the_mount_path_and_nothing_else`, +> with a recorded positive control. + **Environment leak check** (a secret can hide in env, not just on disk): ```sh @@ -403,21 +414,35 @@ detail is in git, and anything durable a run found has been promoted into the ba belongs to rather than left here to be re-read — that promotion is the point of the compression, not a side effect of it. -- **2026-09-01** — **Full A–G**, branch `kaish-0.17`, run because the `kaish-kernel` - 0.14.1 → 0.17.0 bump trips the kernel/VFS trigger. **All clear.** This bump moved the - *instrument* more than any before it: three pass criteria in this file were false - against 0.17 and are corrected in place (Battery A's `ln -s` reason, Battery B's 127 - message, Battery C's now non-empty `env`), and **Battery G is new** for the symlink - boundary 0.17's lstat-by-default opened. - - **The one new observable, accepted:** a link inside the tree pointing outside now - renders its target *string*. G3 is why that is acceptable — existing, missing, and - unreadable targets refuse byte-identically, so there is no existence oracle. - - **Best find:** 0.16 fixed an `env` that bypassed the external-commands gate, and - **kaibo was never exposed** — lever (0) compiles `subprocess` out, verified against - both versions. The four-levers design paying for itself. - - Suites: containment 24, full `cargo test` 1327 passed / 0 failed. +- **2026-09-02** — **Full A–G**, branch `kaish-0.17.1`, run because the `kaish-kernel` + 0.17.0 → 0.17.1 patch touches the VFS and so trips the trigger. **All clear.** Every + battery was run against **both** pins and diffed; A, B, D, E, F and G came back + byte-identical, so the two changes below are the whole delta a model can see. + - **The release blocker is fixed:** `readlink -f` and `realpath` resolve an in-tree + path (exit 0) and refuse an escape by name, where 0.17.0 failed on every operand + with `No such file or directory: /tmp`. G3 re-run on the new canonicalize path — + existing, missing, and unreadable targets still refuse byte-identically. + - **The one new observable, accepted:** the directories *above* the mount list again, + each naming only the next component down to the project. Battery C's `/home` note + is corrected in place. Synthesis, not host reads — counted it: `ls /tmp` returns one + entry where the host holds 3575. Adjacent secrets, siblings, and the state db and + media CAS all stay invisible (E2/F2 re-run). + - **The probe caught itself once:** E1 run without `--root` created a state db, because + the fixture was then outside every allowed tree and the guard correctly did not fire. + The §0 question — would this read differently if the probe were broken? — is what + found it. + - Suites: containment 25 (one new), full `cargo test` 1147 passed. The lone failure is + the known `tests/credentials.rs` ETXTBSY exec race under parallelism; green serially, + reproduces on unmodified code. - §7 not re-run; deferred to the v0.4.0 pre-release check. +- **2026-09-01** — Full A–G, branch `kaish-0.17`, for the 0.14.1 → 0.17.0 bump. All + clear. Three pass criteria here were false against 0.17 and were corrected in place; + **Battery G is new** for the symlink boundary lstat-by-default opened. Its accepted + observable: a link pointing outside renders its target *string*, safe because G3 shows + no existence oracle. Best find: 0.16 fixed an `env` that bypassed the external-commands + gate and **kaibo was never exposed** — lever (0) compiles `subprocess` out. + - **2026-08-13** — Full A–E plus the new Battery F and a §7 model-driven pass, main `fb5ae71`, ahead of v0.3.0. All clear. Both findings were about the *instrument* and both now live in §0: Battery A as written proved nothing (`$ROOT` is empty inside diff --git a/tests/containment.rs b/tests/containment.rs index 579dcfb..0137bc3 100644 --- a/tests/containment.rs +++ b/tests/containment.rs @@ -990,13 +990,20 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { let secret = outside.path().join("outside_secret.txt"); fs::write(&secret, "outside-contents-xyz\n").unwrap(); let missing = outside.path().join("definitely-not-here.txt"); - assert!(!missing.exists(), "the missing-target fixture must not exist"); + assert!( + !missing.exists(), + "the missing-target fixture must not exist" + ); // A third existence state: present on disk but unreadable. The refusal must not // distinguish it either — if kaibo ever stats a target to check permissions before // the path-escape check, this arm is the one that catches it. let unreadable = outside.path().join("unreadable.txt"); fs::write(&unreadable, "unreadable-contents\n").unwrap(); - fs::set_permissions(&unreadable, std::os::unix::fs::PermissionsExt::from_mode(0o000)).unwrap(); + fs::set_permissions( + &unreadable, + std::os::unix::fs::PermissionsExt::from_mode(0o000), + ) + .unwrap(); std::os::unix::fs::symlink(&secret, allowed.path().join("to_existing")).unwrap(); std::os::unix::fs::symlink(&missing, allowed.path().join("to_missing")).unwrap(); @@ -1018,7 +1025,11 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { // --- half 2a: no bytes cross ---------------------------------------------- // `cat` is covered by the sibling test; `stat -L` and `wc -c` are the verbs 0.17 // newly split from their lstat forms, so they are the ones worth pinning here. - for script in ["cat to_existing", "stat -L to_existing", "wc -c to_existing"] { + for script in [ + "cat to_existing", + "stat -L to_existing", + "wc -c to_existing", + ] { let out = try_run(&handler, &root, script).await; let text = match &out { Ok(t) => t.clone(), @@ -1073,3 +1084,103 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { "the shared outcome must be the path-escape refusal, got: {existing_shape}" ); } + +// --- (5c) mount-layer probe: the mount's ancestors are walkable ------------- + +/// kaish 0.17.1 restored a mount point's *ancestors* to navigable directories: with a +/// backend at `/` and the project mounted deeper, `ls`, `stat`, `cd`, and the file +/// tests answered "not found" for every directory above the project, and now they +/// synthesize as directories. kaibo always mounts `/`, so this reaches the shell we +/// hand a model on every call. +/// +/// Accepted, on the condition this test states: **the chain is synthesized from +/// kaibo's own mount table, not read from the host.** Each ancestor lists exactly the +/// one component that leads to the mount, so a model walking up from the project +/// recovers only the root path string the caller already handed it — a sibling +/// directory, a file dropped in an ancestor, and its bytes all stay invisible. +/// +/// If the synthesis ever starts reading the host, this test fails: the sibling +/// appears in a listing, or the file's name or contents cross. That is an escalation, +/// not a re-baseline. +/// +/// The recorded positive control: move the mount itself up by handing `outer.path()` +/// to both `handler_with_allowed` and `root`. The sibling is then genuinely inside the +/// mount, and the first leak assertion fires — so a run where every probe came back +/// empty cannot pass as a clean one. +#[tokio::test] +async fn mount_layer_ancestors_synthesize_the_mount_path_and_nothing_else() { + // outer/{project, SIBLING_DIR, ancestor_secret.txt} — only `project` is allowed. + let outer = tempdir().unwrap(); + let project = outer.path().join("project"); + fs::create_dir(&project).unwrap(); + fs::write(project.join("inside.txt"), "inside-contents\n").unwrap(); + fs::create_dir(outer.path().join("SIBLING_DIR")).unwrap(); + fs::write( + outer.path().join("ancestor_secret.txt"), + "ancestor-secret-xyz\n", + ) + .unwrap(); + + let handler = handler_with_allowed(Some(&project), &[]); + let root = project.to_string_lossy().to_string(); + let outer_abs = outer.path().display().to_string(); + + // --- the chain is walkable, and lists only the way down to the mount ------ + let listing = try_run(&handler, &root, &format!("ls {outer_abs}")) + .await + .expect("the mount's parent is a synthesized directory, so listing it succeeds"); + assert!( + listing.contains("project"), + "the ancestor listing names the component leading to the mount, got: {listing}" + ); + assert!( + !listing.contains("SIBLING_DIR") && !listing.contains("ancestor_secret"), + "HOST LEAK: an ancestor listed a real sibling of the project — the chain is \ + being read from the host, not synthesized from the mount table. Escalate \ + before shipping. Got: {listing}" + ); + + // --- and nothing in an ancestor is reachable ----------------------------- + // Each probe names what would count as a leak: the secret's bytes, or its name + // appearing where the shell found it rather than where the script typed it. + let text_of = |r: Result| match r { + Ok(t) => t, + Err(e) => e, + }; + for script in [ + format!("cat {outer_abs}/ancestor_secret.txt"), + format!("grep -rn ancestor-secret-xyz {outer_abs}"), + format!("grep -rn ancestor-secret-xyz {outer_abs}/ancestor_secret.txt"), + ] { + let text = text_of(try_run(&handler, &root, &script).await); + assert!( + !text.contains("ancestor-secret-xyz"), + "HOST LEAK: `{script}` returned bytes from beside the project through the \ + synthesized ancestor chain. Escalate before shipping. Got: {text}" + ); + } + // `find` and `ls` echo their operand in a refusal, so the leak they can commit is + // reporting a path they DISCOVERED. Both must come back with nothing found. + let found = text_of(try_run(&handler, &root, &format!("find {outer_abs} -type f")).await); + assert!( + !found.contains("ancestor_secret.txt"), + "HOST LEAK: `find` walked the synthesized chain into real host entries. \ + Escalate before shipping. Got: {found}" + ); + let sibling = text_of(try_run(&handler, &root, &format!("ls {outer_abs}/SIBLING_DIR")).await); + assert!( + sibling.contains("not found") || sibling.contains("permission denied"), + "HOST LEAK: a real sibling directory of the project listed successfully \ + through the synthesized chain. Escalate before shipping. Got: {sibling}" + ); + + // The positive control: without it, a run where every probe came back empty + // because the shell was broken would pass every assertion above. + let inside = try_run(&handler, &root, "cat inside.txt") + .await + .expect("reading inside the mount succeeds"); + assert!( + inside.contains("inside-contents"), + "the probe must be able to read the project it is pointed at, got: {inside}" + ); +} From e16c2db6732ffbe31620b78c1efe0097d9c394e5 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Wed, 2 Sep 2026 19:44:14 -0400 Subject: [PATCH 2/2] test(containment): the ancestor claim covers the chain, and the oracle it could hide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-family review (kaibo cast `crusoe`, DS4-Flash explorer + GLM-5.2 synth) found three real gaps in the previous commit. Every citation checked before acting. The test's doc comment claimed "each ancestor lists exactly the one component" and then probed one level — the immediate parent. A synthesis bug a level higher would have fired nothing. The fixture is now two levels deep, each with a sibling that must never appear, so the test proves what it says. The bigger miss: it covered bytes (`cat`, `grep`) and discovery (`ls`, `find`) but not existence. `stat` and `realpath` answer "does this exist" without returning any bytes, and `realpath` resolves for the first time in this very release — so that pair is where an oracle would most plausibly have arrived. Measured: a real host file beside the chain and a path that was never created refuse byte-identically, and only the synthesized components resolve. Now pinned, in the shape Battery G3 uses for links. Third, Battery C's opening pass criterion still read "everything outside the single mount comes back `not found`", contradicting the carve-out two lines below it. Scoped to reads, which is what it always meant. The positive control was re-run against the rewritten test: move the mount up and the leak assertion fires. Reviewed-by: kaibo cast `crusoe` (DeepSeek-V4-Flash explorer, GLM-5.2 synth) Co-Authored-By: Claude Opus 5 --- docs/sandbox-probes.md | 23 +++++--- tests/containment.rs | 130 ++++++++++++++++++++++++++++------------- 2 files changed, 104 insertions(+), 49 deletions(-) diff --git a/docs/sandbox-probes.md b/docs/sandbox-probes.md index 4f54064..ea869c8 100644 --- a/docs/sandbox-probes.md +++ b/docs/sandbox-probes.md @@ -147,12 +147,12 @@ ls ~/*.txt ; echo "glob-out=$?" find /etc -maxdepth 1 ; echo "find-out=$?" ``` -**Pass:** everything outside the single mount comes back `not found` — out-of-mount +**Pass:** every *read* outside the single mount comes back `not found` — out-of-mount paths (including `..`-normalized ones) route into the empty `/` MemoryFs scratch and 404. The adjacent API-key files must be **unreadable**; that's the headline result. -The mount's own prefix (`/home`, `/home/`, …) *lists* — see the note below — but -each level names only the next component toward the mount, so it can't be walked to a -sibling. +The mount's own prefix *directories* (`/home`, `/home/`, …) are the one exception +and they list — see the note below — but each level names only the next component +toward the mount, so it can't be walked to a sibling. > **`cd / && ls` returns `dev`, `home`, `v`, and that is not a finding.** It is > synthetic VFS scaffolding, not host content: `/dev/{null,random,urandom,zero}` are @@ -170,8 +170,14 @@ sibling. > mount** — the host's real siblings, files, and bytes stay absent — so a model walking > up recovers the root path string the caller already handed it and nothing else. Check > it by counting: `ls /tmp` returns one entry where the host `/tmp` holds thousands. -> Pinned by `containment.rs::mount_layer_ancestors_synthesize_the_mount_path_and_nothing_else`, -> with a recorded positive control. +> +> **And check the oracle, the way G3 does for links:** `stat` and `realpath` on a real +> host file beside the chain, and on a path that was never created, must refuse +> byte-identically once the operand is removed — those two verbs answer "does this +> exist" without returning bytes, and `realpath` resolves for the first time in 0.17.1. +> Only the synthesized components resolve. Pinned by +> `containment.rs::mount_layer_ancestors_synthesize_the_mount_path_and_nothing_else`, +> which checks two chain levels and carries a recorded positive control. **Environment leak check** (a secret can hide in env, not just on disk): @@ -425,8 +431,9 @@ compression, not a side effect of it. - **The one new observable, accepted:** the directories *above* the mount list again, each naming only the next component down to the project. Battery C's `/home` note is corrected in place. Synthesis, not host reads — counted it: `ls /tmp` returns one - entry where the host holds 3575. Adjacent secrets, siblings, and the state db and - media CAS all stay invisible (E2/F2 re-run). + entry where the host holds 3575, and `stat`/`realpath` cannot tell a real host file + beside the chain from one that was never created. Adjacent secrets, siblings, and + the state db and media CAS all stay invisible (E2/F2 re-run). - **The probe caught itself once:** E1 run without `--root` created a state db, because the fixture was then outside every allowed tree and the guard correctly did not fire. The §0 question — would this read differently if the probe were broken? — is what diff --git a/tests/containment.rs b/tests/containment.rs index 0137bc3..4c7bbe4 100644 --- a/tests/containment.rs +++ b/tests/containment.rs @@ -1093,64 +1093,80 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { /// synthesize as directories. kaibo always mounts `/`, so this reaches the shell we /// hand a model on every call. /// -/// Accepted, on the condition this test states: **the chain is synthesized from -/// kaibo's own mount table, not read from the host.** Each ancestor lists exactly the -/// one component that leads to the mount, so a model walking up from the project -/// recovers only the root path string the caller already handed it — a sibling -/// directory, a file dropped in an ancestor, and its bytes all stay invisible. +/// Accepted, on the conditions this test states. **The chain is synthesized from +/// kaibo's own mount table, not read from the host**, in three parts: /// -/// If the synthesis ever starts reading the host, this test fails: the sibling -/// appears in a listing, or the file's name or contents cross. That is an escalation, -/// not a re-baseline. +/// 1. **Every level lists only the one component leading to the mount.** Checked at +/// two levels, because the claim is about the chain and one level would leave a +/// higher-level synthesis bug unpinned. +/// 2. **Nothing beside the chain is reachable** — a sibling directory, a file dropped +/// in an ancestor, and its bytes all stay absent from `cat`, `grep`, `find`, `ls`. +/// 3. **No existence oracle on a named ancestor path.** `stat` and `realpath` are the +/// verbs that would answer "does this exist" without returning bytes, and `realpath` +/// is new to this release — so an existing host file beside the chain and an absent +/// one must refuse identically. Only the synthesized components resolve. +/// +/// So a model walking up recovers the root path string the caller already handed it, +/// and nothing else. If any part fails, that is an escalation, not a re-baseline. /// /// The recorded positive control: move the mount itself up by handing `outer.path()` -/// to both `handler_with_allowed` and `root`. The sibling is then genuinely inside the -/// mount, and the first leak assertion fires — so a run where every probe came back +/// to both `handler_with_allowed` and `root`. The siblings are then genuinely inside +/// the mount, and the leak assertions fire — so a run where every probe came back /// empty cannot pass as a clean one. #[tokio::test] async fn mount_layer_ancestors_synthesize_the_mount_path_and_nothing_else() { - // outer/{project, SIBLING_DIR, ancestor_secret.txt} — only `project` is allowed. + // outer/OUTER_SIBLING + // outer/mid/MID_SIBLING + // outer/mid/project <- the only allowed tree + // outer/mid/ancestor_secret.txt + // Two chain levels, each with a sibling that must never appear. let outer = tempdir().unwrap(); - let project = outer.path().join("project"); - fs::create_dir(&project).unwrap(); + let mid = outer.path().join("mid"); + let project = mid.join("project"); + fs::create_dir_all(&project).unwrap(); fs::write(project.join("inside.txt"), "inside-contents\n").unwrap(); - fs::create_dir(outer.path().join("SIBLING_DIR")).unwrap(); - fs::write( - outer.path().join("ancestor_secret.txt"), - "ancestor-secret-xyz\n", - ) - .unwrap(); + fs::create_dir(outer.path().join("OUTER_SIBLING")).unwrap(); + fs::create_dir(mid.join("MID_SIBLING")).unwrap(); + let secret = mid.join("ancestor_secret.txt"); + fs::write(&secret, "ancestor-secret-xyz\n").unwrap(); + let absent = mid.join("definitely-not-here.txt"); + assert!(!absent.exists(), "the absent-file fixture must not exist"); let handler = handler_with_allowed(Some(&project), &[]); let root = project.to_string_lossy().to_string(); let outer_abs = outer.path().display().to_string(); + let mid_abs = mid.display().to_string(); - // --- the chain is walkable, and lists only the way down to the mount ------ - let listing = try_run(&handler, &root, &format!("ls {outer_abs}")) - .await - .expect("the mount's parent is a synthesized directory, so listing it succeeds"); - assert!( - listing.contains("project"), - "the ancestor listing names the component leading to the mount, got: {listing}" - ); - assert!( - !listing.contains("SIBLING_DIR") && !listing.contains("ancestor_secret"), - "HOST LEAK: an ancestor listed a real sibling of the project — the chain is \ - being read from the host, not synthesized from the mount table. Escalate \ - before shipping. Got: {listing}" - ); - - // --- and nothing in an ancestor is reachable ----------------------------- - // Each probe names what would count as a leak: the secret's bytes, or its name - // appearing where the shell found it rather than where the script typed it. let text_of = |r: Result| match r { Ok(t) => t, Err(e) => e, }; + + // --- 1: each level lists only the way down to the mount ------------------- + for (level, listed, sibling) in [ + (&mid_abs, "project", "MID_SIBLING"), + (&outer_abs, "mid", "OUTER_SIBLING"), + ] { + let listing = try_run(&handler, &root, &format!("ls {level}")) + .await + .expect("an ancestor of the mount is a synthesized directory, so listing it succeeds"); + assert!( + listing.contains(listed), + "the ancestor listing names the component leading to the mount, got: {listing}" + ); + assert!( + !listing.contains(sibling) && !listing.contains("ancestor_secret"), + "HOST LEAK: `ls {level}` listed a real sibling — the chain is being read \ + from the host, not synthesized from the mount table. Escalate before \ + shipping. Got: {listing}" + ); + } + + // --- 2: nothing beside the chain is reachable ----------------------------- for script in [ - format!("cat {outer_abs}/ancestor_secret.txt"), - format!("grep -rn ancestor-secret-xyz {outer_abs}"), - format!("grep -rn ancestor-secret-xyz {outer_abs}/ancestor_secret.txt"), + format!("cat {mid_abs}/ancestor_secret.txt"), + format!("grep -rn ancestor-secret-xyz {mid_abs}"), + format!("grep -rn ancestor-secret-xyz {mid_abs}/ancestor_secret.txt"), ] { let text = text_of(try_run(&handler, &root, &script).await); assert!( @@ -1167,13 +1183,45 @@ async fn mount_layer_ancestors_synthesize_the_mount_path_and_nothing_else() { "HOST LEAK: `find` walked the synthesized chain into real host entries. \ Escalate before shipping. Got: {found}" ); - let sibling = text_of(try_run(&handler, &root, &format!("ls {outer_abs}/SIBLING_DIR")).await); + let sibling = text_of(try_run(&handler, &root, &format!("ls {mid_abs}/MID_SIBLING")).await); assert!( sibling.contains("not found") || sibling.contains("permission denied"), "HOST LEAK: a real sibling directory of the project listed successfully \ through the synthesized chain. Escalate before shipping. Got: {sibling}" ); + // --- 3: no existence oracle on a named ancestor path ---------------------- + // `stat` and `realpath` answer "does this exist" without returning bytes, and + // `realpath` resolves for the first time in this release — so this is the verb + // pair most likely to have gained an oracle. A real file beside the chain and a + // path that was never created must be indistinguishable. + for verb in ["stat", "realpath"] { + let present = + text_of(try_run(&handler, &root, &format!("{verb} {}", secret.display())).await); + let missing = + text_of(try_run(&handler, &root, &format!("{verb} {}", absent.display())).await); + // The refusal echoes the operand twice — as typed, and again root-relative with + // the leading slash stripped. Both are the path the script itself supplied, so + // both are normalized away; what must not differ is anything else. + let shape = |t: &str, path: &std::path::Path| { + let abs = path.display().to_string(); + t.replace(&abs, "") + .replace(abs.trim_start_matches('/'), "") + }; + assert_eq!( + shape(&present, &secret), + shape(&missing, &absent), + "EXISTENCE ORACLE: `{verb}` told a real file beside the chain apart from one \ + that was never created — the synthesized chain can be used to probe the host \ + one path at a time. Escalate before shipping." + ); + // A shared success would compare equal too, so name the shared outcome. + assert!( + present.contains("not found"), + "the shared outcome must be a refusal, got: {present}" + ); + } + // The positive control: without it, a run where every probe came back empty // because the shell was broken would pass every assertion above. let inside = try_run(&handler, &root, "cat inside.txt")