Skip to content

A worktree link vouches only in the shape git writes it - #191

Merged
tobert merged 1 commit into
mainfrom
worktree-link-shape
Sep 10, 2026
Merged

tobert merged 1 commit into
mainfrom
worktree-link-shape

Conversation

@tobert

@tobert tobert commented Sep 10, 2026

Copy link
Copy Markdown
Owner

The mutual handshake that shipped in #184 verifies that two files name each other. A kaibo deliberation over the release candidate (cast gpt-deliberate — GPT-5.6-sol reasoning offline over a GPT-5.6-luna dossier) made the case that agreement is not provenance, and the case holds.

vouched_worktrees read the back-link from whatever path the registration named. Nothing required that path to be <worktree>/.git. So two ordinary files naming each other were enough: <repo>/.git/worktrees/evil/gitdir naming <outside>/marker, and marker naming the registration back, vouched for <outside>. Separately, a symlinked worktrees/<name> entry let the registration live outside the common dir entirely, so a directory the common dir does not hold could still hand out a vouch.

Both routes still require the attacker to write a file inside the directory they want kaibo to read — which is the access the design says they lack, and the reason the back-link works at all. So this is the accepted shape widening past git's own layout rather than a hole of the kind #184 closed. Amy read it that way too and chose to hold 0.5.0 for the fix rather than ship and follow: the release's headline is this boundary, and a security claim is worth stating only as narrowly as it is true.

Three checks, each pinned by exactly one test, each test run against the code with that check removed:

  • the registration canonicalizes to a direct child of <common>/worktrees
  • the registration names <wt_root>/.git, not some other file
  • that .git canonically lives in the root it vouches for

A third route the deliberation raised — <tree>/.git symlinked at an outside repo — was probed and is already refused by the tree-side handshake. The probe stays as a regression test so a later change cannot quietly drop that property.

The two real-git worktree add positives were sabotage-checked as well: with the vouch emptied, both fail. They prove the mutual check still accepts the layouts git actually writes, rather than passing because git was missing.

Suite 1356/0, clippy clean.

What this does not do: make the link files trustworthy. Text cannot authenticate its author. What it does is narrow the shape that vouches to one an attacker must write inside the directory being reached. The module doc now says that in as many words, because the old wording implied the handshake alone settled it.

🤖 Generated with Claude Code

The mutual handshake shipped in #184 checks that two files name each other.
A kaibo deliberation (cast `gpt-deliberate`, GPT-5.6-sol over a GPT-5.6-luna
dossier) argued that agreement is not provenance, and the argument holds: the
back-link was read from whatever path the registration named, with no
requirement that it be `<worktree>/.git`. Two ordinary files naming each other
— `<repo>/.git/worktrees/evil/gitdir` naming `<outside>/marker`, and `marker`
naming the registration back — vouched for `<outside>`. A symlinked
`worktrees/<name>` entry likewise let the registration itself live outside the
common dir.

Both routes still require writing a file inside the directory being reached,
which is the access the whole design says an attacker lacks — so this is the
shape of the vouch widening past git's own layout, not a hole of the kind #184
closed. Amy's call was to hold 0.5.0 and fix it rather than ship and follow.

Three checks, one test each, each test run against the code without its check:
the registration canonicalizes to a direct child of `<common>/worktrees`, the
registration names `<wt_root>/.git`, and that file canonically lives in the root
it vouches for. A third route the deliberation raised — `<tree>/.git` symlinked
at an outside repo — was probed and already refused by the tree-side handshake;
that probe stays as a regression test.

The two real-`git worktree add` positives were sabotage-checked as well: with
the vouch emptied they fail, so they prove the mutual check still accepts the
layouts git actually writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewed-by: kaibo cast `gpt-deliberate` (GPT-5.6-sol / GPT-5.6-luna)
@tobert
tobert merged commit ba50d0a into main Sep 10, 2026
1 check passed
@tobert tobert mentioned this pull request Sep 10, 2026
tobert added a commit that referenced this pull request Sep 11, 2026
Version `0.4.0` → `0.5.0`, a fresh empty unreleased section, and
`docs/sandbox-probes.md` stamped with the run that gates this release.

**Minor, not patch**, for two reasons. #182 removed
`explorer_max_turns`/`synth_max_turns` as tool arguments and CLI flags —
a caller-facing break. And the read boundary changed four times: three
worktree-follow escapes closed in #184, then #191 requiring git's own
*shape* after a kaibo deliberation argued that mutual agreement between
two files is not provenance.

The changelog section was shortened before retitling, per the rule that
entries get shorter over time — 70 lines to 52, no bullet dropped. The
four bullets that each narrowed the same worktree rule now say what they
narrow in one line apiece.

## Gates

| gate | result |
|---|---|
| `cargo tree -i` aws-lc-rs / aws-lc-sys / mimalloc / openssl-sys |
absent, negative control prints `kaish-kernel v0.17.2` |
| musl build | `statically linked`, `not a dynamic executable`, runs |
| suite | 1363 passed, 0 failed |
| sandbox battery A–H | all clear |

## The battery

Run because two triggers fired at once: `kaish-vfs` moved with the
0.17.2 bump (#187), and #191 changed containment itself.

Highlights worth reading rather than trusting: every out-of-root read
came back `not found` including `~/.deepseek-key`, which holds a real
key this session — absence would have passed vacuously, so the file
existing is what makes the result mean anything. The prefix listing was
*counted*: `ls /home/atobey` returns 1 entry where the host holds 74.
Battery D's `..` row canonicalizes to `/etc` and is refused by
containment with the message naming the allowed set, not by a resolution
failure. G3's three refusals stay byte-identical across exists / missing
/ unreadable, so a hostile repo gets no existence oracle.

**The F3 this runbook has asked for since 2026-08-13 is now covered, and
it split in two.** `write_cas` — the model-reachable deposit — refuses a
source outside the allowed set, with the positive control run so the
refusal is not vacuous. The CLI `kaibo cas write` does *not* refuse an
outside source, and that is correct rather than a finding: the CLI
caller is the operator, who can already read the file. H1's note reads
as though one rule covers both surfaces; it covers the tool.

**The probe caught itself again**, which is what §0 exists for. The
Battery H fixture put `--state-db` inside the tree it passed as
`--root`, so E1 fired, persistence went off, the CAS fell back to memory
and `cas write` refused — three batteries' worth of correct behavior
reading, at a glance, like a broken store.

Two message drifts recorded, neither a hole: `kill 1` now answers
`signalling a PID requires the subprocess capability`, and `grep`
following an escaping symlink refuses with exit 1 and *no* stderr line,
where every other verb in G2 names the reason — worth knowing before
someone reads that silence as success.

## After merge

Tag `v0.5.0` to fire the release matrix, then verify a downloaded asset
the way a user would (`gh attestation verify`, `cosign verify-blob`) and
run `scripts/bump-tap.sh v0.5.0`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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