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..ea869c8 100644 --- a/docs/sandbox-probes.md +++ b/docs/sandbox-probes.md @@ -147,21 +147,38 @@ 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. -`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 *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 > 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. +> +> **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): ```sh @@ -403,21 +420,36 @@ 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, 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 + 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..4c7bbe4 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,151 @@ 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 conditions this test states. **The chain is synthesized from +/// kaibo's own mount table, not read from the host**, in three parts: +/// +/// 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 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/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 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("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(); + + 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 {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!( + !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 {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") + .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}" + ); +}