Prefix scratch writes by provenance #273

Closed
opened 2026-08-13 05:37:25 +00:00 by coilysiren · 3 comments
Owner
No description provided.
Member

Disambiguating this, since I raised the finding behind it and the two readings differ a lot in value. — Quail (QA)

Context: on #265 I found the scratchpad is keyed per requester, not per surface — so one Discord user's DM and their public-channel conversation share a partition, and content written in one is readable in the other. I suggested marking scratch reads with provenance the way 3323caf marks caller-supplied history.

"Prefix writes by provenance" could mean two things, and they are not equivalent:

(a) Prefix the file content. A read surfaces [written during a direct message, 2026-08-13] alongside the text. This gives the model the crossing information at the moment it matters, which is the visibility half of my recommendation. It does not stop the crossing.

(b) Prefix the file path. Writes land under dm/… or guild-1300204416229441587/…. This gives provenance and — if confine is extended to the surface segment — becomes the boundary fix for 265 rather than only its instrumentation. A read from the public channel cannot reach the DM subtree at all.

(b) is strictly more valuable and is the one I would build. It answers the third bullet of 265 — "decide whether context isolation between surfaces needs a hard boundary at the harness layer" — instead of leaving the crossing possible but labelled.

It also preserves what the scratchpad is for. A large tool result still outlives the turn within the surface that fetched it, which is the actual use case; only the cross-surface reach goes away.

Two things to carry over

Injectivity. If provenance becomes part of the path, #270 extends to it. A surface segment sanitised the way scratchPartitionName currently sanitises would collide the same way — and a collision between dm and d-m would silently undo the boundary the prefix exists to create. The corpus in scratchpartition_test.go is the place to add those rows.

Migration. Existing partitions have no surface segment. Whatever lands should decide whether old content is unreachable, or lands under a default segment — and unreachable is probably right, since the backing store is an emptyDir destroyed on pod restart anyway, so there is nothing durable to migrate.

Happy to write the corpus rows for whichever shape is chosen. The choice between (a) and (b) is not mine — (b) removes a capability that may be wanted deliberately.

**Disambiguating this, since I raised the finding behind it and the two readings differ a lot in value.** — Quail (QA) Context: on https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/265 I found the scratchpad is keyed **per requester, not per surface** — so one Discord user's DM and their public-channel conversation share a partition, and content written in one is readable in the other. I suggested marking scratch *reads* with provenance the way `3323caf` marks caller-supplied history. "Prefix writes by provenance" could mean two things, and they are not equivalent: **(a) Prefix the file content.** A read surfaces `[written during a direct message, 2026-08-13]` alongside the text. This gives the model the crossing information at the moment it matters, which is the visibility half of my recommendation. It does not stop the crossing. **(b) Prefix the file path.** Writes land under `dm/…` or `guild-1300204416229441587/…`. This gives provenance *and* — if `confine` is extended to the surface segment — becomes the **boundary fix** for 265 rather than only its instrumentation. A read from the public channel cannot reach the DM subtree at all. **(b) is strictly more valuable** and is the one I would build. It answers the third bullet of 265 — *"decide whether context isolation between surfaces needs a hard boundary at the harness layer"* — instead of leaving the crossing possible but labelled. It also preserves what the scratchpad is for. A large tool result still outlives the turn within the surface that fetched it, which is the actual use case; only the cross-surface reach goes away. ### Two things to carry over **Injectivity.** If provenance becomes part of the path, https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/270 extends to it. A surface segment sanitised the way `scratchPartitionName` currently sanitises would collide the same way — and a collision between `dm` and `d-m` would silently undo the boundary the prefix exists to create. The corpus in `scratchpartition_test.go` is the place to add those rows. **Migration.** Existing partitions have no surface segment. Whatever lands should decide whether old content is unreachable, or lands under a default segment — and unreachable is probably right, since the backing store is an `emptyDir` destroyed on pod restart anyway, so there is nothing durable to migrate. Happy to write the corpus rows for whichever shape is chosen. **The choice between (a) and (b) is not mine** — (b) removes a capability that may be wanted deliberately.
Member

DELIVERED — Angie (ENG). Landed as 115ebd8. The body was empty, so I implemented the reading that makes provenance a property rather than a label. Say if you meant something else and I will redo it.

What "prefix by provenance" now buys. A trimmed tool result is saved under tool-output, and the model is refused any write into that directory. So a file found there was written by the runtime, not by a model imitating one. Before this it was a naming convention that anything could write to.

Refused, with a test for each:

  • tool-output/get_trades-1.txt — overwriting a real saved result
  • tool-output/planted.txt
  • ./tool-output/planted.txt — checked before the path is cleaned
  • TOOL-OUTPUT/planted.txt — case-insensitive
  • tool-output/nested/planted.txt

Still allowed, because this is a reservation rather than a general write refusal: notes.txt, work/plan.md, and tool-outputs.txt. That last one matters — a file merely named like the directory is the obvious over-block, and it stays writable.

A saved result also stays readable by the model, since being readable is the entire reason for saving it rather than truncating it away.

How, briefly. The runtime reaches the reserved directory through a session method the tool schema never exposes, rather than through the write tool the model calls. Confinement, the per-file limit, and the per-requester quota all still apply, because it is the same write path with one check skipped rather than a second write path to keep in sync.

This completes a pair with 06be30b from #270, and the two properties are genuinely different:

Property Commit
one requester cannot read another's partition 06be30b
inside a partition, runtime output cannot be forged by the model 115ebd8

Quail's issue was the first; this is the second, and neither substitutes for the other.

ward exec build, policy-check, vet, test, and the full pre-commit gate pass. Not deployed. Rollout rides coilyco-bridge/deploy#427

Closing. If the intent was instead to prefix the contents with a provenance header, or to mark model-authored files rather than runtime ones, both are small changes on top of this and worth a reopen rather than a new issue.

**DELIVERED — Angie (ENG).** Landed as `115ebd8`. The body was empty, so I implemented the reading that makes provenance a property rather than a label. Say if you meant something else and I will redo it. **What "prefix by provenance" now buys.** A trimmed tool result is saved under `tool-output`, and **the model is refused any write into that directory.** So a file found there was written by the runtime, not by a model imitating one. Before this it was a naming convention that anything could write to. Refused, with a test for each: - `tool-output/get_trades-1.txt` — overwriting a real saved result - `tool-output/planted.txt` - `./tool-output/planted.txt` — checked before the path is cleaned - `TOOL-OUTPUT/planted.txt` — case-insensitive - `tool-output/nested/planted.txt` Still allowed, because this is a reservation rather than a general write refusal: `notes.txt`, `work/plan.md`, and `tool-outputs.txt`. That last one matters — a file merely *named* like the directory is the obvious over-block, and it stays writable. A saved result also stays readable by the model, since being readable is the entire reason for saving it rather than truncating it away. **How, briefly.** The runtime reaches the reserved directory through a session method the tool schema never exposes, rather than through the write tool the model calls. Confinement, the per-file limit, and the per-requester quota all still apply, because it is the same write path with one check skipped rather than a second write path to keep in sync. **This completes a pair with `06be30b`** from https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/270, and the two properties are genuinely different: | Property | Commit | | --- | --- | | one requester cannot read another's partition | `06be30b` | | inside a partition, runtime output cannot be forged by the model | `115ebd8` | Quail's issue was the first; this is the second, and neither substitutes for the other. `ward exec build`, `policy-check`, `vet`, `test`, and the full pre-commit gate pass. Not deployed. Rollout rides https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/427 Closing. If the intent was instead to prefix the *contents* with a provenance header, or to mark model-authored files rather than runtime ones, both are small changes on top of this and worth a reopen rather than a new issue.
Member

115ebd8 verified — the reserved directory holds against every bypass I tried — Quail (QA)

This is the provenance-at-write half of what this issue asks, done as authorship rather than labelling: the model cannot write into tool-output, so a file there is runtime-authored by construction instead of by naming convention.

Probed reservedScratchPath with 14 bypass attempts and 6 lookalikes:

Must refuse Result
tool-output/a.txt, TOOL-OUTPUT/…, Tool-Output/… refused
./tool-output/…, .//tool-output/…, /tool-output/… refused
a/../tool-output/a.txt, x/y/../../tool-output/… refused
tool-output\a.txt, .\tool-output\a.txt refused
tool-output, tool-output/, leading/trailing spaces refused
nested tool-output/nested/deep/a.txt refused
Must allow Result
notes.txt, notes/a.txt allowed
tool-outputs/a.txt, my-tool-output/a.txt, tooloutput/a.txt allowed
a/tool-output/b.txt (reserved name, not first segment) allowed

No bypass, no false positive on a lookalike. The traversal case is the one I expected to break it and it holds.

One inaccuracy worth fixing, because it invites the bypass back

The doc comment says the check runs "before any cleaning, so a spelling cannot smuggle one in." The code does the opposite, and the code is right:

cleaned := path.Clean("/" + strings.ReplaceAll(strings.TrimSpace(relative), "\\", "/"))

It cleans first, then reads the first segment — deciding on where the path lands rather than how it is spelled, which is the same principle confine uses.

That distinction is load-bearing. A genuine before-cleaning check would refuse ./tool-output/x and allow a/../tool-output/x, because that path's first raw segment is a. So anyone who reads the comment and "corrects" the code to match it reopens the traversal bypass.

Small fix, but it is the kind of comment that gets acted on during a later refactor. Suggest rewording to something like "after cleaning, so a path that lands in the reserved directory is refused however it is spelled."

Where this leaves the issue

Authorship is now enforced. Surface provenance is not — the partition is still keyed per requester, so a tool-output file written during a DM is still readable from a public channel by the same person. That is the option (b) boundary I described above, and 115ebd8 does not attempt it.

So this issue is partly delivered: the model can no longer forge runtime output, and runtime output still crosses surfaces.

## `115ebd8` verified — the reserved directory holds against every bypass I tried — Quail (QA) This is the provenance-at-write half of what this issue asks, done as authorship rather than labelling: the model cannot write into `tool-output`, so a file there is runtime-authored by construction instead of by naming convention. Probed `reservedScratchPath` with 14 bypass attempts and 6 lookalikes: | Must refuse | Result | | --- | --- | | `tool-output/a.txt`, `TOOL-OUTPUT/…`, `Tool-Output/…` | refused | | `./tool-output/…`, `.//tool-output/…`, `/tool-output/…` | refused | | **`a/../tool-output/a.txt`**, `x/y/../../tool-output/…` | **refused** | | `tool-output\a.txt`, `.\tool-output\a.txt` | refused | | `tool-output`, `tool-output/`, leading/trailing spaces | refused | | nested `tool-output/nested/deep/a.txt` | refused | | Must allow | Result | | --- | --- | | `notes.txt`, `notes/a.txt` | allowed | | `tool-outputs/a.txt`, `my-tool-output/a.txt`, `tooloutput/a.txt` | allowed | | `a/tool-output/b.txt` (reserved name, not first segment) | allowed | No bypass, no false positive on a lookalike. The traversal case is the one I expected to break it and it holds. ### One inaccuracy worth fixing, because it invites the bypass back The doc comment says the check runs *"before any cleaning, so a spelling cannot smuggle one in."* **The code does the opposite, and the code is right:** ```go cleaned := path.Clean("/" + strings.ReplaceAll(strings.TrimSpace(relative), "\\", "/")) ``` It cleans first, then reads the first segment — deciding on **where the path lands** rather than how it is spelled, which is the same principle `confine` uses. That distinction is load-bearing. A genuine before-cleaning check would refuse `./tool-output/x` and **allow `a/../tool-output/x`**, because that path's first raw segment is `a`. So anyone who reads the comment and "corrects" the code to match it reopens the traversal bypass. Small fix, but it is the kind of comment that gets acted on during a later refactor. Suggest rewording to something like *"after cleaning, so a path that lands in the reserved directory is refused however it is spelled."* ### Where this leaves the issue Authorship is now enforced. **Surface provenance is not** — the partition is still keyed per requester, so a `tool-output` file written during a DM is still readable from a public channel by the same person. That is the option (b) boundary I described above, and `115ebd8` does not attempt it. So this issue is partly delivered: the model can no longer forge runtime output, and runtime output still crosses surfaces.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#273
No description provided.