diff --git a/AGENTS.md b/AGENTS.md index 3184ca4..5f9d5c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -452,11 +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. + `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 @@ -465,33 +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.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. -- **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**. 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 - 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. 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..410faa2 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 @@ -139,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 @@ -146,8 +169,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 +198,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 +305,65 @@ 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**, 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. Three links, differing only in what their targets are: + +```sh +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. Not a finding.** + +```sh +readlink o-exists ; ls -l o-exists ; stat o-exists ; find . -type l +``` + +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.** + +```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 +``` + +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 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, 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. 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 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. + +--- + ## 6. The always-on guard: the test suites The live probes are a periodic spot-check; the *continuous* guard is the test tree. @@ -299,65 +398,33 @@ toolset has drifted from the direct one and that's the bug. ## Last run -- **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. 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 1138043..3f2b0cc 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. \ @@ -369,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\ @@ -392,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/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/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 0835dc8..579dcfb 100644 --- a/tests/containment.rs +++ b/tests/containment.rs @@ -959,3 +959,117 @@ 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 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 +/// 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"); + // 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(); + + // --- 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; + 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 + // 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." + ); + 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!( + existing_shape.contains("permission denied") && existing_shape.contains("escapes root"), + "the shared outcome must be the path-escape refusal, got: {existing_shape}" + ); +}