Skip to content

chore(kaish): upgrade the read-only shell to kaish-kernel 0.17.0 - #173

Merged
tobert merged 5 commits into
mainfrom
kaish-0.17
Sep 1, 2026
Merged

chore(kaish): upgrade the read-only shell to kaish-kernel 0.17.0#173
tobert merged 5 commits into
mainfrom
kaish-0.17

Conversation

@tobert

@tobert tobert commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Jumps kaish-kernel 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."

Stacked on #172 (the one-brace fix that makes tests/config.rs compile again). Base retargets to main automatically when that merges.

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 rather than rendering a default label, mirroring the parse direction in config::merge_kaish, which refuses an unrecognized scope outright rather than falling back.

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 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:

  • 0.16 made grep -r prefix hits with the operand as written, matching GNU. The grep -rn PATTERN . idiom our sandbox addendum teaches therefore started emitting ./src/foo.rs:12 for every citation the explorer earns. Measured:

    grep -rn needle .          →  ./d/a.txt:1:alpha needle     ← what we taught
    grep -rn needle            →  d/a.txt:1:alpha needle       ← bare operand
    grep -rn needle top.txt    →  1:gamma needle               ← no filename at all
    

    We now teach the bare form, the only one that always yields a repo-relative file:line. The old sentence's promise that the idiom works "whether the target is a file or a directory" went with it — the file case drops the filename, which is the half a citation needs.

    Deliberately not a kaish ask. 0.16's behavior is GNU-exact on purpose; 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 sandbox boundary moved, and we accept it on a measured condition

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 outright.

We accept this rather than narrow it, because the property that makes it safe was measured rather than assumed — there is no existence oracle:

link target cat / stat -L / file / wc [[ -e ]]
/etc/hostname (exists) permission denied: path escapes root false
/etc/DEFINITELY-NOT-HERE (missing) byte-identical refusal false
/root/.ssh/id_rsa (unreadable) byte-identical refusal false

The refusal is decided by path arithmetic before any syscall reaches the target, so a hostile repo gets back exactly the string it wrote into its own link — nothing about the host, not even existence. And 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.

mount_layer_symlink_discloses_its_target_string_but_no_host_fact states both halves and fails loudly if the second ever weakens. Its leak assertion has a recorded positive control: point the link at an in-tree file carrying the marker and the assertion fires.

Full sandbox battery A–G re-run live

A kernel/VFS bump trips the probe trigger. All clear. Three pass criteria in docs/sandbox-probes.md were false against 0.17 and are corrected in place (Battery A's ln -s refusal reason, Battery B's 127 message, Battery C's now non-empty env). 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. Lever (0) compiles subprocess out, so the host-spawn path it escaped through does not exist here — verified against both versions. That is the four-levers design paying for itself.

Gates

  • cargo build, cargo clippy --all-targets clean
  • 1320 tests pass, 0 fail (the one known tests/credentials.rs ETXTBSY parallel flake passes serially and reproduces on unmodified code)
  • cargo tree -i empty for aws-lc-rs, mimalloc, openssl-sys
  • All six kaish crates at 0.17.0 in lockstep in Cargo.lock

Sent upstream, not fixed here

Two kernel bugs found while probing, reported to kaish-lead for 0.17.1:

  1. ! binds only in a condition position, contradicting the 0.16.0 changelog's claim that ! true && true is (! true) && true. if ! false and while ! true work; ! true && echo x is a parse error at 1:1.
  2. readlink -f fails on every operand on a rooted LocalFs mount — an in-root regular file, an in-root link, a dangling link, and a missing file all return No such file or directory: /tmp, naming neither the operand nor its target. Plain readlink is fine.

🤖 Generated with Claude Code

@tobert

tobert commented Sep 1, 2026

Copy link
Copy Markdown
Owner Author

Cross-family review — kaibo cast crusoe (DS4-Flash explorer, GLM-5.2 synth)

Dogfooded per the project's pre-merge rule: a different model lineage than wrote the change, aimed at the whole change rather than the diff. Every citation was verified against the tree before acting on it — presence is not support.

Two real findings, both fixed in 0f8a058. Two claims I checked and did not take.

Finding 1 (must fix, taken) — the grep fix was applied to one surface and missed five

I changed KAISH_SANDBOX_ADDENDUM and stopped. The review found the same grep -rn PATTERN . idiom in five other model- and operator-facing surfaces:

surface who reads it
src/consult/prompts.rs — explorer preamble every sweep
src/server/mod.rs — MCP run_kaish tool description the calling agent, resident
src/server/mod.rskaibo://tools doc the calling agent, on demand
src/kaish_syntax.rskaibo://kaish/sandbox recipes the model, on demand
src/cli.rskaibo kaish -c help the operator

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 this change existed to prevent. The review's framing of this as the headline finding is correct and I've recorded it as the durable lesson in AGENTS.md: when a kaish idiom changes, grep for it, because it is never in one place.

Also taken: kaibo://kaish/sandbox's exit-code list still quoted the pre-0.16 bare command not found.

Deliberately not changed, and the review agreed on the first: test fixtures that merely contain the idiom (progress.rs, consult/engine.rs) are inputs, not teaching. And 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 ./.

Finding 2 (should fix, taken) — the containment test promised three existence states and built two

The doc comment claimed an existing target, a missing one, and an unreadable one are indistinguishable; the test created only to_existing and to_missing. That gap was load-bearing, because the no-existence-oracle property is the justification for accepting the target-string disclosure at all — and as the review put it, 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.

left:  "exit: 1 ... permission denied: path escapes root: <TARGET> is not under /tmp/..."
right: "exit: 0 --- stdout --- inside"

Checked and not taken

  • IgnoreScope wildcard arm — review concurred it is correct as written. The renderer returns a plain String with no error channel, #[non_exhaustive] requires some arm, and unreachable!() would panic on a variant a future kaish release may legitimately add. Naming the variant is the option that neither hides the gap nor invents a label.
  • Timing oracle — raised as a theoretical uncovered channel. Not taken: the refusal is decided by path arithmetic before any syscall reaches the target, which is why there is nothing to time. A timing assertion in a unit test would be flaky and would pin the implementation rather than the property.
  • --json line anchors, $(cmd) typed values, ls -R — review found no kaibo impact; I confirmed independently. orientation.rs is the only place kaibo parses kaish JSON (glob, stat, wc -l), none of which carry line anchors, and its tests run through the real kernel.

Gates after the fixes

1319 pass, 0 fail, plus the known tests/credentials.rs ETXTBSY parallel flake that passes serially and reproduces on unmodified code.

Base automatically changed from fix-config-test-brace to main September 1, 2026 13:21
tobert and others added 3 commits September 1, 2026 09:24
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 <noreply@anthropic.com>
…e state

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@tobert

tobert commented Sep 1, 2026

Copy link
Copy Markdown
Owner Author

The release waits for kaish 0.17.1 — this PR does not

Amy's call, verbatim: "we're not gonna release kaibo until we get on 0.17.1, so those symlink issues won't ship."

What merges vs. what ships. This PR merges and main goes to kaish 0.17.0, so development continues on the new pin. v0.4.0 does not get cut until the pin is 0.17.1. The split is deliberate: a merge is reversible and a release is not.

What it keeps out of a release. The bump's own probe run found readlink -f broken on every operand on a rooted LocalFs mount — an in-root regular file, an in-root link, a dangling link, and a missing file all return No such file or directory: /tmp, naming neither the operand nor its target. Plain readlink is fine. Reported upstream; kaish-lead has it for 0.17.1 and has been told it is now release-blocking for us rather than nice-to-have.

To be clear about which symlink item this is: the target-string disclosure documented above is not a bug and is not what we are waiting on — it is measured, accepted, tested, and documented in Battery G. It is readlink -f being broken that holds the release.

The rule this produced (61d345b, in AGENTS.md's Cutting a release):

"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.

The old gate said to confirm the pins are "current", and that was not strong enough here — this bump's probe run is what found the bug, so "we are on the current pin" and "we are on a known-broken pin" were true at the same moment. Being on the newest tag is not the same as being on a good one. Shipping it anyway would have been a decision rather than an oversight, which is the distinction the rule now names.

Rebased onto the merged main

#171 (transient-transport retry) and #172 (the brace) are both in main now, so this rebased onto 7b126c2. Re-ran the gates against the rebased tree rather than trusting GitHub's MERGEABLE:

  • cargo clippy --all-targets clean
  • 1327 tests pass, 0 fail
  • the known tests/credentials.rs ETXTBSY parallel-exec flake still appears on some parallel runs and passes serially; it reproduces on unmodified code and is unrelated to this change

tobert and others added 2 commits September 1, 2026 09:52
…ey serve

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 <noreply@anthropic.com>
…the PR

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 <noreply@anthropic.com>
@tobert
tobert merged commit f741a84 into main Sep 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant