From f6415c98b59d4dd421516e1ca6f51eb1fbb31d4d Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 1 Sep 2026 09:07:58 -0400 Subject: [PATCH 1/5] chore(kaish): upgrade the read-only shell to kaish-kernel 0.17.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jumps 0.14.1 -> 0.17.0, inheriting three releases' breaking changes at once. Amy: "once kaish 0.17 drops we'll upgrade and release kaibo 0.4." Compile-time exposure was one line pair, exactly what the 2026-08-21 pre-bump audit predicted: `IgnoreScope` became `#[non_exhaustive]` in 0.16 and broke the exhaustive match in `server/config_resource.rs`. The wildcard arm names the unrecognized variant instead of rendering a default label, mirroring the parse direction in `config::merge_kaish`, which refuses an unrecognized `scope` outright. Everything else was free: kaibo builds no `ExecContext` literal (0.17's other BREAKING item), uses no plan-side redaction API, walks no AST, and 0.16's `execute -> Result<_, KernelError>` flows through `anyhow`'s `?` unchanged. The compiler found less than the shell did, and that is the lesson worth keeping. One compile error, but four behavioral changes reached the model-facing surface and only running the shell found them. Two made our own prose false: - 0.16 made `grep -r` prefix hits with the operand as written, matching GNU. The `grep -rn PATTERN .` idiom the sandbox addendum teaches therefore started emitting `./src/foo.rs:12` for every citation the explorer earns. We now teach the bare `grep -rn PATTERN`, the only form that always yields a repo-relative `file:line` — a named file drops the filename entirely, which is the half a citation needs, so the old sentence's promise that the idiom works "whether the target is a file or a directory" went with it. Not a kaish ask: 0.16's behavior is GNU-exact on purpose, and asking kaish to diverge for our convenience would trade a correct rule for a cosmetic one. - 0.16 replaced the bare `command not found` for a refused external command with a message that names the refusal. The addendum quoted the old text. Two more arrived free through `kaish-help`, which kaibo composes rather than restates: compound statements now feed pipes, and `yes`/`no` stopped being lexer errors. The bump also moved the sandbox boundary. 0.17's lstat-by-default means a symlink inside the project pointing outside now renders its target path string through `ls -l`/`stat`/`readlink`/`find -type l`, where 0.14 refused. We accept that rather than narrow it, on a condition measured rather than assumed: there is no existence oracle. Existing, missing, and unreadable targets refuse byte-identically, decided by path arithmetic before any syscall reaches the target, so a hostile repo gets back only the string it wrote into its own link. A link's target is bytes stored inside the allowed tree, so reading it is reading project content; refusing would make `ls -l` misdescribe a directory kaibo is allowed to list. The new containment test states both halves and fails loudly if the second ever weakens. Full battery A-G re-run live, since a kernel/VFS bump trips the probe trigger. All clear. Three pass criteria in the runbook were false against 0.17 and are corrected in place; Battery G is new for the symlink boundary. Worth recording: 0.16 fixed an `env` that bypassed the external-commands gate, and kaibo was never exposed, because lever (0) compiles `subprocess` out and the host-spawn path it escaped through does not exist here. 1320 tests pass, 0 fail. `cargo tree -i` empty for aws-lc-rs, mimalloc, and openssl-sys; all six kaish crates at 0.17.0 in lockstep. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 55 +++++++++---- CHANGELOG.md | 20 +++++ Cargo.lock | 69 +++++++++++++--- Cargo.toml | 2 +- docs/sandbox-probes.md | 143 ++++++++++++++++++++++++++++++++-- src/kaish_syntax.rs | 20 ++++- src/server/config_resource.rs | 13 +++- tests/containment.rs | 97 +++++++++++++++++++++++ 8 files changed, 376 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3184ca4..5d6a5f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,19 +465,43 @@ 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.14.1"`. The `0.14.0 → 0.14.1` bump is a - patch release — **zero** call-site changes, `cargo build`/`clippy --all-targets`/full - `cargo test` (749 passed) all clean, `cargo tree -i aws-lc-rs` and `-i mimalloc` both - empty, no **BREAKING** entries. Every changelog entry is a fix inside the shell - language itself, which model-facing kaish inherits for free with no Rust-side change: - `${s[0:5]}` string slicing (characters, not bytes; `${s:0:5}` — bash's different - convention — is now a loud error instead of silently vanishing); a pipeline's first - stage, `read`, and `grep`/`cat`'s streaming path no longer drop or skip buffered - stdin; `read` on empty stdin now fails instead of binding `""`; and `exit`/`return`/ - `break`/`continue` now carry a block's already-printed output out instead of - discarding it. Net effect for kaibo: scripts the explorer already writes (a `read` - into a pipeline, an early `exit` inside a loop) now behave the way bash would, where - before they silently lost output or stdin. +- **kaish pin.** Currently `kaish-kernel = "0.17.0"`, a 0.14.1 → 0.17.0 jump that + inherits three releases' breaks at once. **Compile-time exposure was one line pair**, + exactly as the pre-bump audit predicted: `IgnoreScope` went `#[non_exhaustive]` in + 0.16, breaking the exhaustive match at `server/config_resource.rs`. The wildcard arm + we added names the unrecognized variant rather than rendering a default label, + mirroring the parse direction in `config::merge_kaish`, which refuses an unrecognized + `scope` outright. Everything else was free: kaibo builds no `ExecContext` literal + (0.17's other BREAKING item), uses no plan-side redaction API, walks no AST, and + 0.16's `execute → Result<_, KernelError>` flows through `anyhow`'s `?` unchanged + because `KernelError: Error`. +- **The lesson from the 0.17 bump: the compiler found less than the shell did.** One + compile error, but four *behavioral* changes reached the model-facing surface, and + only running the shell found them. Two made kaibo's own prose false and were fixed + in `kaish_syntax.rs`: 0.16 made `grep -r` prefix hits with the operand as written, so + the `grep -rn PATTERN .` idiom we teach started emitting `./src/foo.rs:12` for every + citation (the bare form is now taught, and the old sentence's claim that the idiom + works "whether the target is a file or a directory" went too — a named file drops the + filename, which is the half a citation needs); and 0.16 replaced the bare + `command not found` for a refused external command, which the addendum quoted. Two + more arrived free through `kaish-help`, which kaibo composes rather than restates — + compound statements now feed pipes, and `yes`/`no` stopped being lexer errors. **When + you bump kaish, diff the rendered contract and run the shell; do not stop at a green + build.** A throwaway crate that calls `compose(&Recipe::tool_description(), …)` under + both versions diffs the composed contract in one command. +- **The 0.17 bump also moved the sandbox boundary, and the runbook with it.** + lstat-by-default means a symlink inside the project pointing outside now renders its + target path string through `ls -l`/`stat`/`readlink`/`find -type l`, where 0.14 + refused. Accepted rather than narrowed, on a measured condition: existing, missing, + and unreadable targets refuse **byte-identically**, so there is no existence oracle + and a hostile repo learns only the string it wrote into its own link. A link's target + is bytes inside the tree, so reading it is reading project content. Pinned by + `containment.rs::mount_layer_symlink_discloses_its_target_string_but_no_host_fact` + and by Battery G in `docs/sandbox-probes.md`. Also worth knowing: 0.16 fixed an `env` + that bypassed the external-commands gate, and **kaibo was never exposed** — lever (0) + compiles `subprocess` out, so the host-spawn path it escaped through does not exist + here. That is the four-levers design paying for itself, and it is the kind of + evidence worth recording when it happens. - **The previous pin, kept because the reasoning pattern is the point.** The `0.13.0 → 0.14.0` bump moved **one** call site — a deletion: `kaish_syntax.rs`'s `strip_write_side_paragraphs`, whose target text kaish-help #297 made opt-in via @@ -487,10 +511,7 @@ even for a one-line doc fix. prose edit: a bare comma became an ordinary bareword outside `[...]`/`{...}`, so the preamble's "quote the range, because an unquoted comma splits the argument" rationale went false; the quoted `sed` examples stayed, the false reason didn't, and the test - now asserts the reason's **absence**. Everything else on the 0.14.0 checklist was - inert (`jobs --json`'s new `path` field, lowercase `JobStatus`, `KernelConfig::agent()`'s - `kill_on_parent_death` default) because model-facing kaish never grows `jobs` or - spawns children with `subprocess` off. The lesson worth keeping: kaish's approvals + now asserts the reason's **absence**. The lesson worth keeping: kaish's approvals ledger (`/v/approvals`) was cut before the 0.14.0 tag, so a probe of that path would have reported "not found" either way — an all-clear that would have meant nothing. Check that a probe reports differently if its subject is broken versus if the probe diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fc8d5..39130ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,26 @@ 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 + support, pipeline-stage compound statements, `set -o pipefail`, and base-aware + arithmetic. +- **`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. +- **A refused external command explains itself** — `curl: external commands are not + available in this build of the shell` rather than a bare `command not found`, still + exit 127. +- **`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. +- **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. +- **`${#v}` counts characters, not bytes**, and a non-ASCII value no longer fails to + lex — `v=日本語; echo ${#v}` answers 3. +- **Arithmetic refuses a leading zero and reads other bases** — `$((007))` names the + spelling that works, `$((0xff))` is 255. + - **kaibo now fetches a generated artifact's URL when that is how a provider delivers it**, over TLS and size-bounded, instead of refusing it — otherwise the operator fetches the link by hand with their key. diff --git a/Cargo.lock b/Cargo.lock index 8cab816..6ff4a08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1799,9 +1799,9 @@ dependencies = [ [[package]] name = "kaish-glob" -version = "0.14.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca46877cd7054bab96febfd74cbbee5171117aeefe3e9f45225d76d149e5b58c" +checksum = "8c827be08f68998d484f1f8ab75bc9286d5af73914e68922e3307df172b70c5f" dependencies = [ "async-trait", "ignore", @@ -1811,18 +1811,18 @@ dependencies = [ [[package]] name = "kaish-help" -version = "0.14.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86524376f903577c9662191da4b332d6252fa4b537c9c836161c96283713df6e" +checksum = "66429f16e23a1cde9d3f31576ee7f5c8702cde9ffe61dcba8d764293fae43141" dependencies = [ "kaish-types", ] [[package]] name = "kaish-kernel" -version = "0.14.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5acec8d7d4a6ddb8d2073cb46fd2e2c073ef1312de9e26e6453173edb90cb8" +checksum = "272ad77a8b76a4421f0825f6a3f933e03565c2cd5695220d94f1393faf17376c" dependencies = [ "anyhow", "ariadne", @@ -1862,14 +1862,18 @@ dependencies = [ "tokio-util", "tracing", "tracing-opentelemetry 0.32.1", + "unicode-ident", + "unicode-normalization", + "unicode-script", + "unicode-security", "unicode-width 0.2.2", ] [[package]] name = "kaish-tool-api" -version = "0.14.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc289a868ae84be26ae734f009c6c9d74730398605fbd589ca0ed69af68b3c5" +checksum = "456b91321479390b3fadc10f5973aa969fec0aa89abb513d671e2218bbd0c4b2" dependencies = [ "async-trait", "clap", @@ -1878,9 +1882,9 @@ dependencies = [ [[package]] name = "kaish-types" -version = "0.14.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff88d6ba8a63a0f86bfe2042dbedd6f619eacb8180642f894a839984db83c2d8" +checksum = "303b23573cb6e586fa3e1207ecb6d0e8778f575b0be180d11298704db34fe592" dependencies = [ "base64 0.22.1", "serde", @@ -1892,13 +1896,14 @@ dependencies = [ [[package]] name = "kaish-vfs" -version = "0.14.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be31036c668524eda6cdf449ec9b7584c83c2632359f5bb9f6122fa7ac998be4" +checksum = "36d977d0d38ca7707d88dc8514319403b364517ebf7afb76251432962a67a93b" dependencies = [ "async-trait", "getrandom 0.3.4", "kaish-types", + "rustix 1.1.4", "tokio", ] @@ -3638,6 +3643,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -4173,6 +4193,31 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/Cargo.toml b/Cargo.toml index 17af6f9..547d693 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.14.1", default-features = false, features = ["localfs"] } +kaish-kernel = { version = "0.17.0", 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 ccdd34e..5065798 100644 --- a/docs/sandbox-probes.md +++ b/docs/sandbox-probes.md @@ -84,9 +84,15 @@ ln -s /etc/passwd $ROOT/passwd_link ; echo "ln=$?" ls $ROOT | grep -iE 'pwn|\.bak|\.copy' ; echo "leftovers=$?" ``` -**Pass:** every write reports a non-zero exit with `permission denied: filesystem -is read-only`; `leftovers` greps empty (`exit 1`). Confirm on the host too — nothing -should exist on real disk: +**Pass:** every write reports a non-zero exit; `leftovers` greps empty (`exit 1`). +Eight of the nine name `permission denied: filesystem is read-only`. **`ln -s` is the +exception since kaish 0.17**, which refuses a cross-mount symlink target *by name* +before the read-only mount is consulted: `/etc/passwd` is on mount `/` and the link is +on the project mount, so the message is `a link cannot cross mounts`. Both are +refusals; only the reason differs. Point `ln -s` at an in-mount target +(`ln -s Cargo.toml link_inside`) to exercise the read-only leg itself, which still +answers `permission denied: filesystem is read-only`. Confirm on the host too — +nothing should exist on real disk: ```sh ls -la "$ROOT" | grep -iE 'pwn|\.bak|\.copy|pwndir' || echo "clean" @@ -112,7 +118,15 @@ exec /bin/sh ; echo "exec=$?" spawn echo hi ; echo "spawn=$?" ``` -**Pass:** every line is `command not found` (`exit 127`). These axes +**Pass:** every line is `exit 127`. **The message changed in kaish 0.16**: a build +without `subprocess` now says `: external commands are not available in this +build of the shell` instead of the bare `command not found` a genuinely-missing +builtin gets — the two were indistinguishable before, and separating them is the +point. Include `env FOO=bar curl …` in the battery: 0.16 fixed an `env` that spawned +the host binary with no capability check. kaibo was never exposed to that one — lever +(0) compiles `subprocess` out, so there was no host-spawn path to reach, and 0.14.1 +already refused it — but the probe belongs here because it is the shape a future +capability regression would take. These axes (`subprocess`/`git`/`host`/`os-integration`) are compiled *out*, not merely blocked — the dangerous surface doesn't exist. (`kill` is the one oddity: it's a registered builtin stub that returns `not supported on this platform` — harmless, it can't @@ -146,8 +160,17 @@ env ; kaish-vars echo "[$ANTHROPIC_API_KEY][$DEEPSEEK_API_KEY][$OPENAI_API_KEY][$HOME][$PATH]" ``` -**Pass:** all empty. The kaibo *process* holds provider keys for its rig clients, but -they are never propagated into the kaish kernel's environment. +**Pass:** every key variable, `$HOME`, and `$PATH` come back empty. The kaibo +*process* holds provider keys for its rig clients, but they are never propagated into +the kaish kernel's environment. + +`env` itself is no longer strictly empty as of kaish 0.16 — it lists two variables the +*kernel* owns, neither inherited from the host: `PIPESTATUS` (the new pipeline-status +list) and `PWD` (which 0.16 made follow `cd` instead of reporting the process's startup +directory). `PWD` is the mount root, which the model is already told, so it discloses +nothing new. Read the pass criterion as *nothing from the host*, not *nothing at all* — +and check the named variables explicitly, since an empty `env` listing would otherwise +pass vacuously the day the kernel stops populating it. --- @@ -166,6 +189,14 @@ These are separate `run_kaish` calls, each with a different `path` arg: **Pass:** the canonicalize-then-`starts_with` check defeats `..` injected into the path arg itself, and a file (vs. directory) is refused at the parameter boundary. +> Which leg catches the `..` row depends on how deep the root sits. From a root four +> levels down, `/../../../../etc` normalizes to `/home/etc`, which does not +> exist, so it is refused at *canonicalization* rather than at the `starts_with` +> check. Both are refusals (exit 3) and both are correct — but if you want to exercise +> the containment leg specifically, use a `..` count that lands on a directory that +> really exists, and confirm the message names the allowed set rather than +> "could not be resolved". + > The table gives the MCP spelling. Over the CLI (`kaibo kaish --path …`) the same > refusal is **exit 3**, with the same message naming the widening knobs. @@ -265,6 +296,70 @@ artifacts, so the enumeration half is the one that proves the design. --- +## 5c. Battery G — a symlink discloses its target, and nothing else + +**New for kaish 0.17.** The kernel now describes a symlink with `lstat` instead of +following it, so `ls -l`, `stat`, `readlink`, and `find -type l` read the *link* where +0.14 refused. A link inside the project pointing outside it therefore renders its +**target path string**. That is accepted, on one condition this battery checks: nothing +else crosses. + +Build the fixture on the host (the mount is read-only, so it cannot be made from +inside), with three links whose targets differ only in whether they exist: + +```sh +ln -s /etc/hostname "$ROOT/o-exists" # target exists +ln -s /etc/DEFINITELY-NOT-HERE "$ROOT/o-missing" # target does not exist +ln -s /root/.ssh/id_rsa "$ROOT/o-noperm" # exists, unreadable to this user +``` + +**G1 — the target string is readable, and that is the intended behavior.** + +```sh +readlink o-exists ; ls -l o-exists ; stat o-exists ; find . -type l +``` + +**Pass:** each names `/etc/hostname`, exit 0. Not a finding. A symlink's target is +bytes stored inside the allowed tree, so reading it is reading project content, and +refusing would make `ls -l` misdescribe a directory kaibo is allowed to list. + +**G2 — no bytes cross.** + +```sh +cat o-exists ; file o-exists ; wc -c o-exists ; checksum o-exists +stat -L o-exists ; cp o-exists /v/x ; grep -rn . o-exists +[[ -e o-exists ]] && echo E || echo NOT_E +``` + +**Pass:** every verb that would *follow* the link refuses with `permission denied: path +escapes root: is not under ` (exit 1), and `[[ -e ]]` / `[[ -r ]]` are +false. Note `stat` and `stat -L` split here — the lstat form succeeds, the follow form +refuses. That split *is* the boundary. + +**G3 — no existence oracle. This is the probe that makes G1 acceptable.** + +```sh +cat o-exists ; cat o-missing ; cat o-noperm +``` + +**Pass:** the three refusals are **byte-identical** once each link's own target string +is removed. The refusal is decided by path arithmetic before any syscall reaches the +target, so a hostile repo learns nothing about the host — not the target's contents, +not its permissions, not even whether it exists. It gets back the string it wrote into +its own link. + +**Fail:** any divergence between the three. A repo that can tell "exists" from "does +not exist" can probe the host filesystem one link at a time, and at that point G1 stops +being acceptable and becomes a disclosure. Escalate rather than re-baseline. + +> Pinned continuously by +> `tests/containment.rs::mount_layer_symlink_discloses_its_target_string_but_no_host_fact`, +> whose leak assertion has a recorded positive control: point the link at an in-tree +> file carrying the marker and the assertion fires. Its sibling +> `mount_layer_symlink_in_allowed_pointing_outside` covers the content half. + +--- + ## 6. The always-on guard: the test suites The live probes are a periodic spot-check; the *continuous* guard is the test tree. @@ -299,6 +394,42 @@ toolset has drifted from the direct one and that's the bug. ## Last run +- **2026-09-01** — Full battery A–G direct via `kaibo kaish`/`kaibo --state-db`/ + `--cas-dir` (built binary, branch `kaish-0.17`), run because the + `kaish-kernel` 0.14.1 → 0.17.0 bump trips the kernel/VFS trigger. **All clear**, and + this bump moved the *instrument* more than any before it — three pass criteria in + this file were false against 0.17 and are now corrected in place (Battery A's `ln -s` + reason, Battery B's 127 message, Battery C's non-empty `env`). + A — nine writes refused, nothing on real disk; `ln -s /etc/passwd` now refuses as + `a link cannot cross mounts` rather than read-only, and `ln -s Cargo.toml link_inside` + was added to exercise the read-only leg itself. B — ten external commands exit 127 + with 0.16's clearer message; **`env FOO=bar curl` refused on both 0.14.1 and 0.17.0, + so kaibo was never exposed to the `env` capability bypass 0.16 fixed** — lever (0) + compiles `subprocess` out, so the host-spawn path it escaped through does not exist + here. C — every out-of-mount read `not found`; the adjacent-secret probe unreadable; + key vars, `$HOME`, `$PATH` all empty, with `env` now listing only kernel-owned + `PIPESTATUS` and `PWD`. D — all five `path` rows exit 3 as expected. E — E1 refused + with no file created, E2's real 4 KiB store unreadable, E3 green (13 tests). + F — CAS refused an in-project `--cas-dir` with nothing created; a **populated** store + (87 shard dirs on the host) neither readable nor listable through kaish. + **G — new.** 0.17's lstat-by-default opened one new observable: a link inside the tree + pointing outside now renders its target string (`ls -l`, `stat`, `readlink`, + `find -type l`). Accepted, because G3 holds — existing, missing, and unreadable + targets refuse **byte-identically**, so there is no existence oracle and a hostile + repo gets back only the string it wrote itself. Every following verb (`cat`, `file`, + `wc`, `checksum`, `stat -L`, `cp`, `grep -r`) still refuses. + Suites green on the same build: containment 24 (one new), full `cargo test` 1139 + passed / 0 failed, with the one known `tests/credentials.rs` ETXTBSY parallel flake + passing serially. §7's model-driven pass not re-run — deferred to the v0.4.0 + pre-release check, where a local cast is available. + One finding about the *instrument*, in this file's own tradition: the first cut of + the new containment test passed for the wrong reason (`run_kaish` reports a refused + builtin as a successful CALL, so both oracle arms landed in `Ok` and the normalizer + never ran), and the leak assertion's first positive control tripped an earlier + assertion instead of the one it meant to test. Both were corrected until the control + landed on the intended line. **Ask of any probe: would it report something different + if the thing it audits were broken, versus if the probe itself were?** + - **2026-06-14** — full battery + suites, commit `a381b25`. All clear: no write reached disk, no external command ran, no read escaped the root, env empty, `path` containment held (incl. `..`-injection), 30/30 boundary tests green. Model-driven diff --git a/src/kaish_syntax.rs b/src/kaish_syntax.rs index 1138043..8f0f4e5 100644 --- a/src/kaish_syntax.rs +++ b/src/kaish_syntax.rs @@ -34,6 +34,15 @@ use crate::config::{CastUsability, Config, Lane, ModelRole}; /// Plain and literal per the agent-facing clarity rule in AGENTS.md — this block is /// embedded in every preamble, so an idiom here is charged to every model we drive. /// +/// The grep idiom is written **without an operand**, and that is deliberate. kaish +/// 0.16 made `grep -r` prefix each hit with the operand as written, matching GNU: an +/// explicit `.` yields `./src/foo.rs:12`, a named file yields no path at all, and only +/// a *defaulted* operand yields the bare `src/foo.rs:12` a citation wants. Since every +/// call starts at the project root, the operand kaibo used to teach only ever added +/// two characters to every citation the explorer earns. The old sentence also promised +/// the idiom worked "whether the target is a file or a directory" — the file case +/// drops the filename, which is the half a citation needs, so the promise went with it. +/// /// Every `sed` range here is written **quoted**, but the addendum no longer explains /// why, because the reason stopped being true. kaish reserved a bare comma through /// 0.13, making `sed -n 120,400p` a parse error; kaish 0.14 made a comma an ordinary @@ -44,16 +53,19 @@ use crate::config::{CastUsability, Config, Lane, ModelRole}; pub const KAISH_SANDBOX_ADDENDUM: &str = "\ In kaibo this shell runs over a READ-ONLY snapshot of one project, offline: writes, \ `git`, `touch`, and external commands are refused, so your work here is reading. Read \ -files WHOLE by default with `cat -n FILE`; `grep -rn PATTERN .` finds matches whether \ -the target is a file or a directory. When a grep hit lands in a large file, read a \ +files WHOLE by default with `cat -n FILE`; `grep -rn PATTERN` searches the \ +whole project and prefixes every hit with its path from the root. When a grep hit \ +lands in a large file, read a \ wide span around it with `cat -n FILE | sed -n '120,400p'`, which returns that range \ with its real line numbers. Run `file FILE` on an unfamiliar file first; it names \ the content as text or binary, so you know what you are about to read. \ Each call starts at the project root; \ there is no persistent cwd. Read the exit code: 0 is success; 3 means the output \ was too large and came back as a head+tail sample (not a failure); 124 means the \ -script was killed for running past its time budget; 127 is command-not-found, which \ -is how every external command answers here; 1 is an ordinary failure, and a refused \ +script was killed for running past its time budget; 127 is how every external \ +command answers here — its message names the refusal, as in `curl: external \ +commands are not available in this build of the shell`; 1 is an ordinary failure, \ +and a refused \ write is one of those — its message reads `permission denied: filesystem is \ read-only`. Read the message and not only the code, because that sentence is what \ tells a refusal apart from a mistake. \ diff --git a/src/server/config_resource.rs b/src/server/config_resource.rs index 2506fb4..583a62d 100644 --- a/src/server/config_resource.rs +++ b/src/server/config_resource.rs @@ -179,7 +179,9 @@ pub(crate) fn render_config_resource( /// User's global gitignore (`core.excludesFile`) honored. global_gitignore: bool, /// `"enforced"` (all walkers incl. `find`) or `"advisory"` (polite tools only). - scope: &'static str, + /// A scope kaish added and kaibo has not mapped renders as + /// `"unrecognized ()"` — never as one of the two kaibo knows. + scope: String, } #[derive(Serialize)] @@ -675,9 +677,14 @@ pub(crate) fn render_config_resource( defaults: ignore.use_defaults(), auto_gitignore: ignore.auto_gitignore(), global_gitignore: ignore.use_global_gitignore(), + // `IgnoreScope` is `#[non_exhaustive]`: a kaish release can add a scope + // kaibo's loader cannot produce. Name it rather than render one of the + // two labels for it — the same posture as the parse direction in + // `config::merge_kaish`, which refuses an unrecognized `scope` outright. scope: match ignore.scope() { - kaish_kernel::IgnoreScope::Enforced => "enforced", - kaish_kernel::IgnoreScope::Advisory => "advisory", + kaish_kernel::IgnoreScope::Enforced => "enforced".to_string(), + kaish_kernel::IgnoreScope::Advisory => "advisory".to_string(), + other => format!("unrecognized ({other:?})"), }, }, }, diff --git a/tests/containment.rs b/tests/containment.rs index 0835dc8..9216b27 100644 --- a/tests/containment.rs +++ b/tests/containment.rs @@ -959,3 +959,100 @@ async fn sweep_attach_refuses_a_symlink_escape() { "a symlink escaping the root must be refused: {receipt}" ); } + +// --- (5b) mount-layer probe: symlink METADATA vs symlink CONTENT ------------- + +/// The companion to [`mount_layer_symlink_in_allowed_pointing_outside`], which pins +/// the *content* half. kaish 0.17 made `ls -l`, `stat`, `readlink`, and `find -type l` +/// describe a symlink with `lstat` instead of following it, so a link inside the +/// allowed tree pointing outside now renders its **target path string** where kaish +/// 0.14 refused. This test states the boundary that replaced it, in two halves: +/// +/// 1. **The target string is readable, and that is deliberate.** A symlink's target +/// is bytes stored inside the allowed tree — reading it is reading project +/// content, and refusing would make `ls -l` misdescribe a directory kaibo is +/// allowed to list. +/// 2. **Nothing else crosses.** Every verb that would *follow* the link out still +/// refuses, and — the property that makes half 1 safe — the refusal is decided by +/// path arithmetic before any syscall reaches the target, so an existing target, a +/// missing one, and an unreadable one are indistinguishable. A hostile repo gets +/// back the string it wrote into its own link and nothing about the host, not even +/// existence. +/// +/// If half 2 ever weakens — outside bytes appear, or the three refusals stop matching +/// — this test fails, and that is an escalation, not a re-baseline: the disclosure in +/// half 1 is only acceptable *because* half 2 holds. +#[tokio::test] +async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { + let allowed = tempdir().unwrap(); + let outside = tempdir().unwrap(); + 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"); + + std::os::unix::fs::symlink(&secret, allowed.path().join("to_existing")).unwrap(); + std::os::unix::fs::symlink(&missing, allowed.path().join("to_missing")).unwrap(); + + let handler = handler_with_allowed(Some(allowed.path()), &[]); + let root = allowed.path().to_string_lossy().to_string(); + + // --- half 1: the link's own target string is readable --------------------- + let shown = try_run(&handler, &root, "readlink to_existing") + .await + .expect("reading the link itself stays inside the tree, so it succeeds"); + assert!( + shown.contains(&secret.display().to_string()), + "`readlink` names the link's target verbatim — that string is project content; \ + got: {shown}" + ); + + // --- 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"] { + let out = try_run(&handler, &root, script).await; + let text = match &out { + Ok(t) => t.clone(), + Err(e) => e.clone(), + }; + assert!( + !text.contains("outside-contents-xyz"), + "MOUNT-LAYER SYMLINK LEAK: `{script}` returned bytes from outside the \ + allowed tree — escalate before shipping. Got: {text}" + ); + } + + // --- half 2b: no existence oracle ----------------------------------------- + // The refusal must be decided by path arithmetic, not by touching the target. So + // an existing target and a missing one must refuse the same way; if they ever + // diverge, a hostile repo can probe the host filesystem one link at a time. + let existing = try_run(&handler, &root, "cat to_existing").await; + let absent = try_run(&handler, &root, "cat to_missing").await; + // `run_kaish` reports a refused builtin as a successful CALL carrying kaish's exit + // code and stderr, so both arms land in `Ok` — normalize the text, not the variant. + // Two things legitimately differ and neither is a host fact: the target path the + // repo itself wrote, and the link's own name. + let shape = |r: &Result, target: &std::path::Path, link: &str| { + let text = match r { + Ok(t) => t.clone(), + Err(e) => e.clone(), + }; + text.replace(&target.display().to_string(), "") + .replace(link, "") + }; + let existing_shape = shape(&existing, &secret, "to_existing"); + assert_eq!( + existing_shape, + shape(&absent, &missing, "to_missing"), + "EXISTENCE ORACLE: a link to an existing outside file and a link to a missing \ + one must be indistinguishable once the repo's own target string is removed — \ + otherwise the tree can be probed one link at a time. Escalate before shipping." + ); + // And the shared outcome must be a refusal, not a shared success — two identical + // `cat`s that both PRINTED the file would also compare equal. + assert!( + existing_shape.contains("permission denied") && existing_shape.contains("escapes root"), + "the shared outcome must be the path-escape refusal, got: {existing_shape}" + ); +} From 784ce7fd9737b554d2af778ede707560ef2e9179 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 1 Sep 2026 09:19:28 -0400 Subject: [PATCH 2/5] fix(kaish): teach one grep idiom, not two, and pin the third existence state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acting on the cross-family review (kaibo cast `crusoe`: DS4-Flash explorer, GLM-5.2 synth). Every citation was verified against the tree before I trusted it; both findings were real. The grep fix was applied to KAISH_SANDBOX_ADDENDUM and missed five other surfaces that still taught `grep -rn PATTERN .`: src/consult/prompts.rs the explorer preamble — the one that drives sweeps src/server/mod.rs the MCP `run_kaish` tool description src/server/mod.rs the `kaibo://tools` doc src/kaish_syntax.rs the `kaibo://kaish/sandbox` browsing recipes src/cli.rs `kaibo kaish -c` help Fixing one was worse than fixing none. A model reads the addendum every turn and the explorer preamble every sweep, so a half-fix taught two contradictory idioms with no way to tell which was right — and the dotted one produces exactly the `./src/foo.rs:12` citations the change existed to prevent. The sandbox resource's exit-code list still quoted the pre-0.16 bare `command not found` for a refused external command; it now names the refusal the way the addendum does. Deliberately left alone: test fixtures that merely contain the idiom (`progress.rs`, `consult/engine.rs`) are inputs, not teaching. A named-directory operand (`grep -rn PATTERN DIR/`) is correct and stays — GNU joins the operand as written, so a named directory still cites a usable path. It is `.` specifically that adds the `./`. Second finding: the new containment test's doc comment promised that an existing target, a missing one, and an unreadable one are indistinguishable, but the test built only two links. That gap mattered because the no-existence-oracle property is the justification for accepting the target-string disclosure at all, and a permission-shaped difference probes the host just as well as an existence-shaped one. Added the third arm (a mode-000 target) with a recorded positive control: point it at an in-tree file and the assertion fires. 1319 pass, 0 fail, plus the known `tests/credentials.rs` ETXTBSY parallel flake that passes serially and reproduces on unmodified code. Reviewed-by: kaibo cast `crusoe` (deepseek-ai/Deepseek-V4-Flash explorer, zai/GLM-5.2 synth) Co-Authored-By: Claude Opus 5 --- AGENTS.md | 19 ++++++++++++++++--- src/cli.rs | 2 +- src/consult/prompts.rs | 2 +- src/kaish_syntax.rs | 10 ++++++---- src/server/mod.rs | 4 ++-- tests/containment.rs | 25 +++++++++++++++++++++---- 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d6a5f1..86b3668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -478,17 +478,30 @@ even for a one-line doc fix. - **The lesson from the 0.17 bump: the compiler found less than the shell did.** One compile error, but four *behavioral* changes reached the model-facing surface, and only running the shell found them. Two made kaibo's own prose false and were fixed - in `kaish_syntax.rs`: 0.16 made `grep -r` prefix hits with the operand as written, so - the `grep -rn PATTERN .` idiom we teach started emitting `./src/foo.rs:12` for every + in prose: 0.16 made `grep -r` prefix hits with the operand as written, so the + `grep -rn PATTERN .` idiom we teach started emitting `./src/foo.rs:12` for every citation (the bare form is now taught, and the old sentence's claim that the idiom works "whether the target is a file or a directory" went too — a named file drops the filename, which is the half a citation needs); and 0.16 replaced the bare - `command not found` for a refused external command, which the addendum quoted. Two + `command not found` for a refused external command, which two surfaces quoted. Two more arrived free through `kaish-help`, which kaibo composes rather than restates — compound statements now feed pipes, and `yes`/`no` stopped being lexer errors. **When you bump kaish, diff the rendered contract and run the shell; do not stop at a green build.** A throwaway crate that calls `compose(&Recipe::tool_description(), …)` under both versions diffs the composed contract in one command. +- **And when a kaish idiom changes, grep for it — it is never in one place.** The + `grep -rn PATTERN .` fix landed first in `KAISH_SANDBOX_ADDENDUM` alone, and the + cross-family review found the same idiom in **five** other model- and operator-facing + surfaces: the explorer preamble (`consult/prompts.rs`), the MCP `run_kaish` tool + description and the `kaibo://tools` doc (`server/mod.rs`), the `kaibo://kaish/sandbox` + recipes (`kaish_syntax.rs`), and the `kaibo kaish -c` help (`cli.rs`). Fixing one is + worse than fixing none: a model reads the addendum every turn *and* the explorer + preamble every sweep, so a half-fix teaches two contradictory idioms and the model has + no way to tell which is right. Test fixtures that merely *contain* the idiom + (`progress.rs`, `consult/engine.rs`) are not part of this — they are inputs, not + teaching. Nor is a named-directory operand (`grep -rn PATTERN DIR/`), which is + correct: GNU joins the operand as written, so a named directory still cites a usable + path. It is `.` specifically that adds the `./`. - **The 0.17 bump also moved the sandbox boundary, and the runbook with it.** lstat-by-default means a symlink inside the project pointing outside now renders its target path string through `ls -l`/`stat`/`readlink`/`find -type l`, where 0.14 diff --git a/src/cli.rs b/src/cli.rs index 7bf6920..f93cd3c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -507,7 +507,7 @@ pub struct DeliberateArgs { pub struct KaishArgs { /// The kaish (sh-like) script to run against the read-only project. Required — kaibo /// has no interactive shell, so `-c` is the only way in (a missing `-c` is a usage - /// error, not a prompt). `cat -n FILE`, `grep -rn PATTERN .`, pipes with jq/awk/find. + /// error, not a prompt). `cat -n FILE`, `grep -rn PATTERN`, pipes with jq/awk/find. #[arg(short = 'c', value_name = "SCRIPT")] pub command: Option, diff --git a/src/consult/prompts.rs b/src/consult/prompts.rs index 2342a7c..2a88d68 100644 --- a/src/consult/prompts.rs +++ b/src/consult/prompts.rs @@ -217,7 +217,7 @@ pub fn report_preamble() -> String { anyway and let the result tell you otherwise.\n\n\ Prefer the bigger read. Reading too much costs you one read. Reading too \ little costs you every read after it.\n\n\ - Use `grep -rn PATTERN .` to find WHICH files matter (`-B4 -A8` shows a \ + Use `grep -rn PATTERN` to find WHICH files matter (`-B4 -A8` shows a \ preview around each match). Once grep names a file, open that file whole. \ When the file is large, read a wide span around each match instead, with \ `cat -n FILE | sed -n '120,400p'`. That keeps the real line numbers, so your \ diff --git a/src/kaish_syntax.rs b/src/kaish_syntax.rs index 8f0f4e5..3f2b0cc 100644 --- a/src/kaish_syntax.rs +++ b/src/kaish_syntax.rs @@ -381,8 +381,9 @@ pub fn kaibo_sandbox_doc() -> String { Lead with line numbers so every claim cites `file:line`, and read files \ whole:\n\ - `cat -n FILE` — the whole file, numbered; the default move on any file that matters\n\ - - `grep -rn PATTERN [PATH]` — find which files matter, then open them whole\n\ - - `grep -rn -B3 -A6 PATTERN .` — preview matches in context across files\n\ + - `grep -rn PATTERN` — find which files matter, then open them whole; every hit is prefixed with its path from the project root\n\ + - `grep -rn -B3 -A6 PATTERN` — preview matches in context across files\n\ + - `grep -rn PATTERN DIR/` — narrow to a subtree; hits are prefixed with the operand as written, so a named directory still cites a usable path\n\ - `grep -rl PATTERN src` — just the file names that match\n\ - `cat -n FILE | sed -n '1200,2400p'` — a targeted wide span of a truncated giant (`grep -n SYMBOL FILE` pins where to aim), and the follow-up to a grep hit in a large file\n\ - `file FILE` — what a file is, text or binary, read from its content rather than its name\n\n\ @@ -404,8 +405,9 @@ pub fn kaibo_sandbox_doc() -> String { - `124` — killed for exceeding the per-exec time budget\n\ - `126` — a builtin the operator disabled in kaibo's config; the default \ config disables none, so you will rarely see this\n\ - - `127` — command not found. Every external command answers this way, which \ - is what makes the host unreachable from here\n\ + - `127` — every external command answers this way, and its message names \ + the refusal (`curl: external commands are not available in this build of \ + the shell`), which is what makes the host unreachable from here\n\ - `130` — the script was cancelled\n\ - other non-zero — the script itself failed\n\n\ ## Learn more kaish\n\ diff --git a/src/server/mod.rs b/src/server/mod.rs index 8ee1942..5c1a0d3 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -2636,7 +2636,7 @@ impl KaiboHandler { #[tool( description = "Run a kaish (sh-like) script against the READ-ONLY project; \ returns exit code + stdout + stderr. Read generously with line numbers — \ - `cat -n FILE` for a whole file, `grep -rn PATTERN .` to locate across \ + `cat -n FILE` for a whole file, `grep -rn PATTERN` to locate across \ files — and compose builtins with pipes (grep/jq/awk/find/...). Writes are \ refused (exit 1, stderr `permission denied: filesystem is read-only`) and \ external commands are unreachable (exit 127); 124 = timed out. \ @@ -4469,7 +4469,7 @@ byte forever. `run_kaish` runs a kaish (sh-like) script against the project and returns exit code + stdout + stderr. Lead with the idioms that produce accurate `file:line`s: `cat -n FILE` to read a file WHOLE (the default; most files fit in one read), -`grep -rn PATTERN .` to find which files matter. A whole read that truncates (exit 3) +`grep -rn PATTERN` to find which files matter. A whole read that truncates (exit 3) still returns the start and end of the file; read the rest as targeted wide spans (`grep -n SYMBOL FILE`, then `cat -n FILE | sed -n '1200,2400p'`). Compose builtins with pipes (`grep`/`jq`/`awk`/`find`/…). Each call starts fresh at the project root. diff --git a/tests/containment.rs b/tests/containment.rs index 9216b27..579dcfb 100644 --- a/tests/containment.rs +++ b/tests/containment.rs @@ -974,10 +974,11 @@ async fn sweep_attach_refuses_a_symlink_escape() { /// allowed to list. /// 2. **Nothing else crosses.** Every verb that would *follow* the link out still /// refuses, and — the property that makes half 1 safe — the refusal is decided by -/// path arithmetic before any syscall reaches the target, so an existing target, a -/// missing one, and an unreadable one are indistinguishable. A hostile repo gets -/// back the string it wrote into its own link and nothing about the host, not even -/// existence. +/// path arithmetic before any syscall reaches the target, so all three existence +/// states — an existing target, a missing one, and one that exists but cannot be +/// read — are indistinguishable. A hostile repo gets back the string it wrote into +/// its own link and nothing about the host: not the contents, not the permissions, +/// not even existence. /// /// If half 2 ever weakens — outside bytes appear, or the three refusals stop matching /// — this test fails, and that is an escalation, not a re-baseline: the disclosure in @@ -990,9 +991,16 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { 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"); + // 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(); std::os::unix::fs::symlink(&secret, allowed.path().join("to_existing")).unwrap(); std::os::unix::fs::symlink(&missing, allowed.path().join("to_missing")).unwrap(); + std::os::unix::fs::symlink(&unreadable, allowed.path().join("to_unreadable")).unwrap(); let handler = handler_with_allowed(Some(allowed.path()), &[]); let root = allowed.path().to_string_lossy().to_string(); @@ -1029,6 +1037,7 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { // diverge, a hostile repo can probe the host filesystem one link at a time. let existing = try_run(&handler, &root, "cat to_existing").await; let absent = try_run(&handler, &root, "cat to_missing").await; + let noperm = try_run(&handler, &root, "cat to_unreadable").await; // `run_kaish` reports a refused builtin as a successful CALL carrying kaish's exit // code and stderr, so both arms land in `Ok` — normalize the text, not the variant. // Two things legitimately differ and neither is a host fact: the target path the @@ -1049,6 +1058,14 @@ async fn mount_layer_symlink_discloses_its_target_string_but_no_host_fact() { one must be indistinguishable once the repo's own target string is removed — \ otherwise the tree can be probed one link at a time. Escalate before shipping." ); + assert_eq!( + existing_shape, + shape(&noperm, &unreadable, "to_unreadable"), + "EXISTENCE ORACLE: a link to a readable outside file and one to an unreadable \ + outside file must be indistinguishable too — a permission-shaped difference \ + probes the host just as well as an existence-shaped one. Escalate before \ + shipping." + ); // And the shared outcome must be a refusal, not a shared success — two identical // `cat`s that both PRINTED the file would also compare equal. assert!( From 61d345bdcad14f65841440875d4408842478e9d1 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 1 Sep 2026 09:30:19 -0400 Subject: [PATCH 3/5] docs(release): do not release on a kaish carrying a known unfixed bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amy, on this bump: "we're not gonna release kaibo until we get on 0.17.1, so those symlink issues won't ship." The existing gate said to confirm the pins are "current", and that was not strong enough to catch this case. Being on the newest tag is not the same as being on a good one — this bump's own probe run is what FOUND `readlink -f` broken on every operand on a rooted mount, so "current" and "known broken" were true at the same moment. Shipping it would have been a decision rather than an oversight, which is the distinction the rule now names. The split it draws: developing on the new pin is fine and continues, because a merge is reversible and a release is not. So this PR still merges; v0.4.0 waits for 0.17.1. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 86b3668..6571ca0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -457,6 +457,15 @@ even for a one-line doc fix. `not a dynamic executable`. Re-run `docs/sandbox-probes.md` when this release changed the sandbox rules or the VFS — a `kaish-kernel` bump is the common case, so check the pin first. + **"Current" is not the whole gate: do not release on a kaish version carrying a known + unfixed bug in a surface kaibo hands a model.** Being on the newest tag is not the + same as being on a good one — a bump can be the *first* thing to find a bug, and then + shipping it is a decision rather than an oversight. When a bump's probe run turns up + kernel bugs, report them upstream and hold the release for the patch; developing on + the new pin meanwhile is fine, because a merge is reversible and a release is not. + Amy set this precedent on 2026-09-01 for the 0.17.0 bump, whose probe run found + `readlink -f` broken on every operand on a rooted mount: *"we're not gonna release + kaibo until we get on 0.17.1, so those symlink issues won't ship."* After the release publishes: run the README "Verify a download" commands against a fresh asset (`gh attestation verify`, `cosign verify-blob` with the new tag's identity) — the tag-gated publish job signs releases, and signing an operator can't From c2b86cd86549d5f89379edbd6eec5b2b7b1074f1 Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 1 Sep 2026 09:52:22 -0400 Subject: [PATCH 4/5] docs(probes): the run log compresses; findings move to the battery they serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amy: "sandbox-probes.md is getting a bit heavy with history, should we streamline that doc?" It was — and the 2026-09-01 entry I had just written was the single heaviest thing in it. Two jobs were tangled: a runbook you read while probing, and a run log that only accreted. The log had reached 99 lines of a 494-line doc across five entries, in no particular chronological order. The rule now stated in the section itself, so it holds without anyone policing it: newest first, the current run in full, older runs compressed to a line. Git has the detail, and this doc is in git. The part that is not mere shortening: durable findings were trapped inside dated entries, where they are read once and then never again by the person who needs them. The 2026-07-29 run recorded that `cd / && ls` returns `dev`, `home`, `v` as synthetic VFS scaffolding rather than host content — a fact you need while reading Battery C's output, not a historical note. It now lives in Battery C, with the confirmation procedure, and is called out as not-a-finding because a reader meeting that listing cold reasonably suspects a hole. The 2026-08-13 run's two instrument lessons were already promoted into section 0, so those entries compress with nothing lost. **That promotion is the point of the compression, not a side effect of it** — the log shrinks because its contents found better homes. Battery G tightened too (63 -> 58); the rest is commands and pass criteria that carry their weight. Net: 494 -> 430 lines, run log 99 -> 31, and the batteries now sit in an even 29-58 band instead of one outlier. Co-Authored-By: Claude Opus 5 --- docs/sandbox-probes.md | 194 ++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 129 deletions(-) diff --git a/docs/sandbox-probes.md b/docs/sandbox-probes.md index 5065798..410faa2 100644 --- a/docs/sandbox-probes.md +++ b/docs/sandbox-probes.md @@ -153,6 +153,15 @@ paths (including `..`-normalized ones) route into the empty `/` MemoryFs scratch `cd ~` / `cd /home/` fail — only the full mount path is a real directory, so the prefix 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. +> 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. + **Environment leak check** (a secret can hide in env, not just on disk): ```sh @@ -298,30 +307,28 @@ artifacts, so the enumeration half is the one that proves the design. ## 5c. Battery G — a symlink discloses its target, and nothing else -**New for kaish 0.17.** The kernel now describes a symlink with `lstat` instead of -following it, so `ls -l`, `stat`, `readlink`, and `find -type l` read the *link* where -0.14 refused. A link inside the project pointing outside it therefore renders its -**target path string**. That is accepted, on one condition this battery checks: nothing -else crosses. +**New for kaish 0.17**, which describes a symlink with `lstat` instead of following it. +A link inside the project pointing outside now renders its **target path string** where +0.14 refused. Accepted, on the one condition G3 checks: nothing else crosses. -Build the fixture on the host (the mount is read-only, so it cannot be made from -inside), with three links whose targets differ only in whether they exist: +Build the fixture on the host — the mount is read-only, so it cannot be made from +inside. Three links, differing only in what their targets are: ```sh -ln -s /etc/hostname "$ROOT/o-exists" # target exists -ln -s /etc/DEFINITELY-NOT-HERE "$ROOT/o-missing" # target does not exist -ln -s /root/.ssh/id_rsa "$ROOT/o-noperm" # exists, unreadable to this user +ln -s /etc/hostname "$ROOT/o-exists" # exists +ln -s /etc/DEFINITELY-NOT-HERE "$ROOT/o-missing" # does not exist +ln -s /root/.ssh/id_rsa "$ROOT/o-noperm" # exists, unreadable ``` -**G1 — the target string is readable, and that is the intended behavior.** +**G1 — the target string is readable. Not a finding.** ```sh readlink o-exists ; ls -l o-exists ; stat o-exists ; find . -type l ``` -**Pass:** each names `/etc/hostname`, exit 0. Not a finding. A symlink's target is -bytes stored inside the allowed tree, so reading it is reading project content, and -refusing would make `ls -l` misdescribe a directory kaibo is allowed to list. +Each names `/etc/hostname`, exit 0. A link's target is bytes stored inside the allowed +tree, so reading it is reading project content; refusing would make `ls -l` misdescribe +a directory kaibo is allowed to list. **G2 — no bytes cross.** @@ -331,32 +338,29 @@ stat -L o-exists ; cp o-exists /v/x ; grep -rn . o-exists [[ -e o-exists ]] && echo E || echo NOT_E ``` -**Pass:** every verb that would *follow* the link refuses with `permission denied: path -escapes root: is not under ` (exit 1), and `[[ -e ]]` / `[[ -r ]]` are -false. Note `stat` and `stat -L` split here — the lstat form succeeds, the follow form -refuses. That split *is* the boundary. +Every verb that *follows* the link refuses `permission denied: path escapes root: + is not under ` (exit 1); `[[ -e ]]` and `[[ -r ]]` are false. `stat` and +`stat -L` split here — lstat succeeds, follow refuses. That split *is* the boundary. -**G3 — no existence oracle. This is the probe that makes G1 acceptable.** +**G3 — no existence oracle. This is what makes G1 acceptable.** ```sh cat o-exists ; cat o-missing ; cat o-noperm ``` **Pass:** the three refusals are **byte-identical** once each link's own target string -is removed. The refusal is decided by path arithmetic before any syscall reaches the -target, so a hostile repo learns nothing about the host — not the target's contents, -not its permissions, not even whether it exists. It gets back the string it wrote into -its own link. +is removed, because the refusal is path arithmetic decided before any syscall reaches +the target. A hostile repo gets back the string it wrote into its own link — not the +target's contents, not its permissions, not even whether it exists. -**Fail:** any divergence between the three. A repo that can tell "exists" from "does -not exist" can probe the host filesystem one link at a time, and at that point G1 stops -being acceptable and becomes a disclosure. Escalate rather than re-baseline. +**Fail:** any divergence. A repo that can tell "exists" from "does not exist" probes the +host one link at a time, and G1 stops being acceptable. Escalate rather than +re-baseline. -> Pinned continuously by -> `tests/containment.rs::mount_layer_symlink_discloses_its_target_string_but_no_host_fact`, -> whose leak assertion has a recorded positive control: point the link at an in-tree -> file carrying the marker and the assertion fires. Its sibling -> `mount_layer_symlink_in_allowed_pointing_outside` covers the content half. +> Pinned by `containment.rs::mount_layer_symlink_discloses_its_target_string_but_no_host_fact` +> (all three arms, with a recorded positive control: point a link at an in-tree file +> carrying the marker and the leak assertion fires) and its sibling +> `mount_layer_symlink_in_allowed_pointing_outside` for the content half. --- @@ -394,101 +398,33 @@ toolset has drifted from the direct one and that's the bug. ## Last run -- **2026-09-01** — Full battery A–G direct via `kaibo kaish`/`kaibo --state-db`/ - `--cas-dir` (built binary, branch `kaish-0.17`), run because the - `kaish-kernel` 0.14.1 → 0.17.0 bump trips the kernel/VFS trigger. **All clear**, and - this bump moved the *instrument* more than any before it — three pass criteria in - this file were false against 0.17 and are now corrected in place (Battery A's `ln -s` - reason, Battery B's 127 message, Battery C's non-empty `env`). - A — nine writes refused, nothing on real disk; `ln -s /etc/passwd` now refuses as - `a link cannot cross mounts` rather than read-only, and `ln -s Cargo.toml link_inside` - was added to exercise the read-only leg itself. B — ten external commands exit 127 - with 0.16's clearer message; **`env FOO=bar curl` refused on both 0.14.1 and 0.17.0, - so kaibo was never exposed to the `env` capability bypass 0.16 fixed** — lever (0) - compiles `subprocess` out, so the host-spawn path it escaped through does not exist - here. C — every out-of-mount read `not found`; the adjacent-secret probe unreadable; - key vars, `$HOME`, `$PATH` all empty, with `env` now listing only kernel-owned - `PIPESTATUS` and `PWD`. D — all five `path` rows exit 3 as expected. E — E1 refused - with no file created, E2's real 4 KiB store unreadable, E3 green (13 tests). - F — CAS refused an in-project `--cas-dir` with nothing created; a **populated** store - (87 shard dirs on the host) neither readable nor listable through kaish. - **G — new.** 0.17's lstat-by-default opened one new observable: a link inside the tree - pointing outside now renders its target string (`ls -l`, `stat`, `readlink`, - `find -type l`). Accepted, because G3 holds — existing, missing, and unreadable - targets refuse **byte-identically**, so there is no existence oracle and a hostile - repo gets back only the string it wrote itself. Every following verb (`cat`, `file`, - `wc`, `checksum`, `stat -L`, `cp`, `grep -r`) still refuses. - Suites green on the same build: containment 24 (one new), full `cargo test` 1139 - passed / 0 failed, with the one known `tests/credentials.rs` ETXTBSY parallel flake - passing serially. §7's model-driven pass not re-run — deferred to the v0.4.0 - pre-release check, where a local cast is available. - One finding about the *instrument*, in this file's own tradition: the first cut of - the new containment test passed for the wrong reason (`run_kaish` reports a refused - builtin as a successful CALL, so both oracle arms landed in `Ok` and the normalizer - never ran), and the leak assertion's first positive control tripped an earlier - assertion instead of the one it meant to test. Both were corrected until the control - landed on the intended line. **Ask of any probe: would it report something different - if the thing it audits were broken, versus if the probe itself were?** - -- **2026-06-14** — full battery + suites, commit `a381b25`. All clear: no write - reached disk, no external command ran, no read escaped the root, env empty, `path` - containment held (incl. `..`-injection), 30/30 boundary tests green. Model-driven - probe re-run on the local `openai` cast (gemma4, after raising its window to 131072) - reproduced the direct results exactly. Update this line each pass; git history is - the rest of the record. -- **2026-07-18** — Batteries A/B/C direct via `kaibo kaish` (built binary, base - commit `c1267bd` + the `kaish-kernel` 0.12.0 → 0.13.0 bump, branch - `chore/kaish-0.13.0`), specifically to spot-check that bump against the sandbox - boundary. All clear: every write in Battery A refused with `permission denied: - filesystem is read-only` and nothing landed on real disk; every external command - in Battery B came back `command not found` (exit 127); every out-of-mount read in - Battery C (`/etc/passwd`, `..` traversal, `~/.ssh`, the adjacent-secret probe) came - back `not found`, and `env`/the key-var check came back empty. Full `cargo test` - (598 passed) green on the same build. Batteries D/E (path containment, the - persistence store) not re-run live this pass — unaffected by a kaish-kernel bump - and already covered by `tests/containment.rs`/`tests/store.rs` in that same green - run. -- **2026-08-13** — Full battery A–E direct via `kaibo kaish` (built binary, main - `fb5ae71`), plus the new Battery F and a model-driven §7 pass, ahead of cutting v0.3.0. - All clear. A — nine writes refused `permission denied: filesystem is read-only`, - leftovers empty, host and `git status` clean. B — nine external commands `command not - found` (exit 127). C — every out-of-mount read `not found`; **all three real key files - on the host and the operator's own `config.toml` confirmed present on disk and - unreadable through kaish**; `env` and `kaish-vars` empty. D — all five `path` rows - matched, `..`-injection canonicalized to `/etc` and refused. E — E1 exit 1 with no file - created, E2's store unreadable *and* unlistable, E3 green (13 tests). **F — new: the - CAS refused an in-project `--cas-dir` with nothing created, and a populated CAS was - neither readable nor listable through kaish.** §7 — the model-driven pass ran on a - local cast (`lfm25-solo`, the one live local endpoint of seven) and matched the direct - runs exactly: write `exit 1`, `whoami` `exit 127`, `/etc/passwd` `exit 1`. Suites - green: containment 23, sandbox 6, run_kaish_tool 14, full `cargo test` exit 0. - Two findings, both about the *instrument* rather than the boundary: Battery A as - written proved nothing (`$ROOT` is empty inside kaish — §0 now says so), and the - `/v/approvals` battery drafted for the kaish 0.14 bump was deleted before it shipped, - because the ledger cut removed the mount it probed. -- **2026-07-29** — Full battery A–E, direct via `kaibo kaish`/`kaibo --state-db` - (built binary, commit `ffb0bdb`, pre-release checklist pass ahead of cutting real - v0.2.0 — five PRs had landed since the last full run: rmcp 3.0.0-beta.5, the - OpenAI batch lane, `list_models`, gemini `base_url`, the Homebrew tap). All clear: - Battery A — every write refused `permission denied: filesystem is read-only`, - `leftovers` grep empty, host `ls` clean. Battery B — every external command - `command not found` (exit 127). Battery C — `/etc/passwd`, `..`-traversal, - `~/.ssh/id_rsa`, and the adjacent-secret probe all `not found`; `env`/`kaish-vars`/ - the key-var check all empty. **Noted for the record** (not a finding): `cd / && ls` - lists `dev`, `home`, `v` — these are synthetic VFS scaffolding, not host content: - `/dev/{null,random,urandom,zero}` are virtual devices, `/v` is kaish's own builtin - toolbox + ephemeral blob/job scratch (confirmed empty), and `/home` is an inert, - unwalkable stub (`ls /home`, `/home/atobey`, `/home/atobey/src` all `not found`) — - only the exact `--root`-resolved absolute path mounts real content, confirmed by - reading `$ROOT/Cargo.toml` through it. Battery D — all five `path` containment - cases matched the expected table exactly (`/etc` and the root's parent refused as - outside the allowed set, the `..`-injected path canonicalized to `/etc` then - refused, a real subdir succeeded, a file path refused as "not a directory"). - Battery E — E1 refused the in-project `--state-db` loudly with no file created; - E2's real on-disk state db (4 KiB, existing session data) read back `not found` - through kaish; E3's `no_write_path` suite (11 tests) green. Full `cargo test` - (502 lib + full integration) and `--test containment --test sandbox - --test run_kaish_tool` (34 tests) all green on the same build. Immediately - followed by the `turso` 0.7.0 → 0.7.1 exact-pin bump (PR #106, merged as - `fae7a26`) — reviewed separately (cross-family, `src/store.rs`'s WAL-reopen - tests) since it doesn't touch this boundary. +Newest first. **The current run is kept in full; older ones compress to a line.** Their +detail is in git, and anything durable a run found has been promoted into the battery it +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. + - §7 not re-run; deferred to the v0.4.0 pre-release check. + +- **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 + kaish), and the drafted `/v/approvals` battery probed a mount kaish had already cut. +- **2026-07-29** — Full A–E, `ffb0bdb`, pre-release pass for v0.2.0. All clear. Its + standing contribution is the `cd /` scaffolding note, now in Battery C. +- **2026-07-18** — A/B/C only, `kaish-kernel` 0.12.0 → 0.13.0 bump. All clear; D/E + skipped as unaffected by a kernel bump and covered by the suites. +- **2026-06-14** — First full battery plus suites, `a381b25`. All clear, including a §7 + model-driven pass that reproduced the direct results exactly. From ef88a4af97695f31d84566ea8ed71cedad1cab3d Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 1 Sep 2026 10:02:17 -0400 Subject: [PATCH 5/5] docs(agents): the bump notes shrink to the rules; the story stays in the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amy: "way too much added to AGENTS.md, can the addition be minimal/none?" It was too much — the first cut added a net 43 lines to a file charged twice over: once to every session here (CLAUDE.md is a symlink to it) and again to every kaibo consult on this repo, since AGENTS.md is the default --project-context-file and gets spliced into the preamble. Four bullets of bump narrative is a per-call tax on a story that only needed telling once. What stays is what changes an agent's behavior next time: the pin version, run the shell rather than trusting a green build, diff the composed contract, and grep for an idiom before assuming it lives in one file. Everything else — which match arm broke, which prose went false, the symlink boundary reasoning, the env-bypass we were structurally immune to — is in PR #173, which becomes the merge commit, and in docs/sandbox-probes.md, which is where someone probing the boundary actually looks. Applied the same discipline to what was already there: the 0.13.0 -> 0.14.0 "previous pin" bullet was three versions stale, and its one durable lesson (check that a probe reports differently if its subject is broken versus if the probe itself is) has lived in sandbox-probes.md section 0 since August, verified before deleting. Its other content described a test that still exists and still runs; the test is the guard, the prose was history. Net: AGENTS.md is 19 lines SHORTER than before the bump. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 94 ++++++++++--------------------------------------------- 1 file changed, 16 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6571ca0..5f9d5c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -452,20 +452,13 @@ even for a one-line doc fix. - **Cutting a release.** Bump `version` in `Cargo.toml`, retitle the unreleased section to `## [X.Y.Z] — ` and open a fresh empty unreleased section above it, then tag `vX.Y.Z` — `.github/workflows/release.yml` builds the platform matrix on a - `v*` tag. Before tagging: confirm the `kaish-kernel` and `turso` pins are current, and - verify `cargo tree -i` is empty for `aws-lc-rs` and `mimalloc` and the musl binary is - `not a dynamic executable`. Re-run `docs/sandbox-probes.md` when this release changed - the sandbox rules or the VFS — a `kaish-kernel` bump is the common case, so check the - pin first. - **"Current" is not the whole gate: do not release on a kaish version carrying a known - unfixed bug in a surface kaibo hands a model.** Being on the newest tag is not the - same as being on a good one — a bump can be the *first* thing to find a bug, and then - shipping it is a decision rather than an oversight. When a bump's probe run turns up - kernel bugs, report them upstream and hold the release for the patch; developing on - the new pin meanwhile is fine, because a merge is reversible and a release is not. - Amy set this precedent on 2026-09-01 for the 0.17.0 bump, whose probe run found - `readlink -f` broken on every operand on a rooted mount: *"we're not gonna release - kaibo until we get on 0.17.1, so those symlink issues won't ship."* + `v*` tag. Before tagging: confirm the `kaish-kernel` and `turso` pins are current + *and* carry no known unfixed bug — current is not the same as good, and a bump is + often what finds one, so hold the release for the patch and keep developing on the + pin (a merge is reversible; a release is not). Verify `cargo tree -i` is empty for + `aws-lc-rs` and `mimalloc` and the musl binary is `not a dynamic executable`. Re-run + `docs/sandbox-probes.md` when this release changed the sandbox rules or the VFS — a + `kaish-kernel` bump is the common case, so check the pin first. After the release publishes: run the README "Verify a download" commands against a fresh asset (`gh attestation verify`, `cosign verify-blob` with the new tag's identity) — the tag-gated publish job signs releases, and signing an operator can't @@ -474,67 +467,12 @@ 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"`, a 0.14.1 → 0.17.0 jump that - inherits three releases' breaks at once. **Compile-time exposure was one line pair**, - exactly as the pre-bump audit predicted: `IgnoreScope` went `#[non_exhaustive]` in - 0.16, breaking the exhaustive match at `server/config_resource.rs`. The wildcard arm - we added names the unrecognized variant rather than rendering a default label, - mirroring the parse direction in `config::merge_kaish`, which refuses an unrecognized - `scope` outright. Everything else was free: kaibo builds no `ExecContext` literal - (0.17's other BREAKING item), uses no plan-side redaction API, walks no AST, and - 0.16's `execute → Result<_, KernelError>` flows through `anyhow`'s `?` unchanged - because `KernelError: Error`. -- **The lesson from the 0.17 bump: the compiler found less than the shell did.** One - compile error, but four *behavioral* changes reached the model-facing surface, and - only running the shell found them. Two made kaibo's own prose false and were fixed - in prose: 0.16 made `grep -r` prefix hits with the operand as written, so the - `grep -rn PATTERN .` idiom we teach started emitting `./src/foo.rs:12` for every - citation (the bare form is now taught, and the old sentence's claim that the idiom - works "whether the target is a file or a directory" went too — a named file drops the - filename, which is the half a citation needs); and 0.16 replaced the bare - `command not found` for a refused external command, which two surfaces quoted. Two - more arrived free through `kaish-help`, which kaibo composes rather than restates — - compound statements now feed pipes, and `yes`/`no` stopped being lexer errors. **When - you bump kaish, diff the rendered contract and run the shell; do not stop at a green - build.** A throwaway crate that calls `compose(&Recipe::tool_description(), …)` under - both versions diffs the composed contract in one command. -- **And when a kaish idiom changes, grep for it — it is never in one place.** The - `grep -rn PATTERN .` fix landed first in `KAISH_SANDBOX_ADDENDUM` alone, and the - cross-family review found the same idiom in **five** other model- and operator-facing - surfaces: the explorer preamble (`consult/prompts.rs`), the MCP `run_kaish` tool - description and the `kaibo://tools` doc (`server/mod.rs`), the `kaibo://kaish/sandbox` - recipes (`kaish_syntax.rs`), and the `kaibo kaish -c` help (`cli.rs`). Fixing one is - worse than fixing none: a model reads the addendum every turn *and* the explorer - preamble every sweep, so a half-fix teaches two contradictory idioms and the model has - no way to tell which is right. Test fixtures that merely *contain* the idiom - (`progress.rs`, `consult/engine.rs`) are not part of this — they are inputs, not - teaching. Nor is a named-directory operand (`grep -rn PATTERN DIR/`), which is - correct: GNU joins the operand as written, so a named directory still cites a usable - path. It is `.` specifically that adds the `./`. -- **The 0.17 bump also moved the sandbox boundary, and the runbook with it.** - lstat-by-default means a symlink inside the project pointing outside now renders its - target path string through `ls -l`/`stat`/`readlink`/`find -type l`, where 0.14 - refused. Accepted rather than narrowed, on a measured condition: existing, missing, - and unreadable targets refuse **byte-identically**, so there is no existence oracle - and a hostile repo learns only the string it wrote into its own link. A link's target - is bytes inside the tree, so reading it is reading project content. Pinned by - `containment.rs::mount_layer_symlink_discloses_its_target_string_but_no_host_fact` - and by Battery G in `docs/sandbox-probes.md`. Also worth knowing: 0.16 fixed an `env` - that bypassed the external-commands gate, and **kaibo was never exposed** — lever (0) - compiles `subprocess` out, so the host-spawn path it escaped through does not exist - here. That is the four-levers design paying for itself, and it is the kind of - evidence worth recording when it happens. -- **The previous pin, kept because the reasoning pattern is the point.** The - `0.13.0 → 0.14.0` bump moved **one** call site — a deletion: `kaish_syntax.rs`'s - `strip_write_side_paragraphs`, whose target text kaish-help #297 made opt-in via - `Concept::Overlay`, so the filter had nothing left to strip and its own guard - assertion caught it (`core_carries_no_write_side_teaching` still passes, now resting - on upstream's default plus kaibo never opting in). It also forced one grammar-driven - prose edit: a bare comma became an ordinary bareword outside `[...]`/`{...}`, so the - preamble's "quote the range, because an unquoted comma splits the argument" rationale - went false; the quoted `sed` examples stayed, the false reason didn't, and the test - now asserts the reason's **absence**. The lesson worth keeping: kaish's approvals - ledger (`/v/approvals`) was cut before the 0.14.0 tag, so a probe of that path would - have reported "not found" either way — an all-clear that would have meant nothing. - Check that a probe reports differently if its subject is broken versus if the probe - itself is. +- **kaish pin.** Currently `kaish-kernel = "0.17.0"` (from 0.14.1, inheriting three + 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 + compilation; two made kaibo's own prose false. Diff the composed contract under both + versions with a throwaway crate calling `compose(&Recipe::tool_description(), …)`, + then probe the shell by hand. And **grep for any idiom you correct — a kaish idiom is + never in one file** (the `grep -rn PATTERN .` fix landed in six). Each bump's own + story stays in its PR.