Skip to content

docs(probes): the CAS write path gets a battery, and it found a missing test - #177

Merged
tobert merged 1 commit into
mainfrom
docs/cas-write-battery
Sep 3, 2026
Merged

docs(probes): the CAS write path gets a battery, and it found a missing test#177
tobert merged 1 commit into
mainfrom
docs/cas-write-battery

Conversation

@tobert

@tobert tobert commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Item two on the v0.4.0 pre-release list, carried since v0.3.0. The media CAS had
containment probes — Battery F: a path inside a project is refused at startup, kaish can
neither read nor enumerate the store — but nothing that measured its write shape.

That shape is the argument the CAS rests on. From AGENTS.md: the store is "safe by shape,
not policy — the address is the content hash, so its API has no destination-path parameter
for a model to aim; it is write-only (create_new; no unlink/truncate/rename, so an edit
is copy-on-write)."
Every clause of that was a sentence we believed and nowhere a number.

Battery H, written from its own first run

Not from reading the code — the probes were run against a scratch store first, and the
battery records what actually came back.

  • The address is the content hash, checkable independently. A stored object's filename
    equals sha256sum of the file that produced it. Two writes of one image make one
    object; a different image makes a second.
  • A repeat write is a no-op, not a rewrite. Inode and mtime unchanged to the
    nanosecond
    . The battery says to compare mtime rather than object count on purpose — a
    rewrite with identical bytes would keep the count and still prove the store mutable.
  • A refused write stores nothing, with the file count taken on both sides. "A
    refusal happened" is not the same claim as "nothing landed."
  • A fresh object stays invisible to kaish, with the host-side listing as the
    denominator. F2 alone would pass against an empty store and prove nothing.
  • The store refuses to be a lie. With persistence off it stores nothing rather than
    hand back a digest that dies the moment the process exits; on tmpfs it stores but warns
    the artifacts will not survive the host. A silent fallback is the failure that would
    matter here.

Also worth noting for what is absent: cas write takes the file to read and nothing
else, and write_cas takes path or content plus a label — where path is a
source, containment-checked like any other read. No write surface accepts a destination.

The gap it found

write_cas's source path is containment-checked, and the out-of-tree case was tested. The
case one indirection along was not: a symlink inside the allowed tree pointing outside
it.
That is the leg read_contained_file's own doc comment calls "the one worth not
skipping" — and that doc points at tests/containment.rs's mount_layer_symlink_*
battery as proof, which drives run_kaish, not this path.

A store that followed such a link would be an exfiltration route with a read-back verb
attached, since read_cas hands the bytes back to the client. The behavior was already
correct — canonicalize resolves the link before the containing-tree check — so this adds
the test, not a fix.

The new test carries a positive control (a real file inside the tree still stores through
the same handler), and the teeth were checked rather than assumed: replace
containing_tree's refusal in read_contained_file with a fallback to the file's own
parent, and both refusal tests fail.

Gates

Suite 1329 passed / 0 failed (one new). cargo clippy --all-targets clean. The diff is
additions only — no reformatting rode along.

No changelog entry: a runbook battery and a test are not user-facing surfaces.

🤖 Generated with Claude Code

…ng test

Item two on the v0.4.0 pre-release list, carried since v0.3.0: the media CAS had
containment probes (Battery F — a path inside a project is refused at startup,
kaish can neither read nor enumerate the store) but nothing that measured its
*write* shape. That shape is the argument the CAS rests on — safe by shape, not
by policy, because the address is the content hash and no write surface takes a
destination. It was a sentence in AGENTS.md and nowhere a number.

Battery H turns it into measurements, and is written from its own first run
rather than from reading the code:

- The stored object's name equals `sha256sum` of the file that produced it, which
  an operator can check independently. Two writes of one image make one object.
- A repeat write leaves inode and mtime unchanged to the nanosecond — `create_new`
  never reopens an occupied address, so stored bytes are immutable. The battery
  says to compare mtime rather than object count, because a rewrite with identical
  bytes would keep the count and still prove the store mutable.
- A refused write leaves the file count where it was, taken on both sides.
- A fresh object stays invisible to kaish, with the host-side listing as the
  denominator — F2 alone would pass against an empty store.
- With persistence off, the store refuses rather than hand back a digest that dies
  at exit. Silent fallbacks are the failure that would matter here.

Writing it found the gap worth having. `write_cas`'s source path is
containment-checked, and the out-of-tree case was tested — but not the case one
indirection along, a symlink inside the tree pointing outside. That is the leg
`read_contained_file`'s own doc calls the one worth not skipping, and its doc
points at `tests/containment.rs`'s `mount_layer_symlink_*` battery for it, which
drives `run_kaish` rather than this path. A store that followed such a link would
be an exfiltration route with a read-back verb attached.

The new test carries a positive control (a real file inside the tree still
stores), and the teeth were checked: replace `containing_tree`'s refusal in
`read_contained_file` with a fallback to the file's own parent and both refusal
tests fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert
tobert merged commit 0aa7fc9 into main Sep 3, 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