fix(outbox,toolbox): the class was every caller-supplied name, and containment was not unreachable

Session 146 fixed `--run-id` across the seven doors and called it a class. It was not: the class
is every caller-supplied value that reaches a file name, and `write_outbox` composes
`{run_id}-{approach_id}`. Measured 20.09 -- `--approach-id a/../../../ESCAPE` answered 0 and put
the artefacts three levels above the directory the caller named. Counted rather than assumed: 11
path compositions in `outbox.py`, 2 such values, both now through one `_checked_name`.

The ledger sentence that said containment was UNREACHABLE after the string rule was untrue, and
the approach-id escape is the disproof -- the removed check would have caught it. It is back, but
in `outbox._artefact_path`, where the composition is, not in the door. That is the difference
that makes it reachable: the string rule lives in the door, while `run.py` hands its own
`--run-id` straight to the writers and goes past it. Checked before the directory is created, so
a refusal leaves nothing behind, and it covers the next flag someone interpolates into a name.

The judge's exact call now answers 3 with 0 files outside. Suite 2291/0/5/5 (746 s), ruff clean,
mypy 0. Both gates re-run after `git add`: v1 exit 1 (0/3, 0/3, 3/8, no report, 3/8, NOT
MEASURED, 1/20), B exit 1 (15/17, 0/2, 15/15 over 516 files, 0/3, 4/5, NOT MEASURED) -- no row
moved, and row 3's denominator held because the probe grew in place rather than as a new file.

Also: the presentation deck said 1.1.0 was the current version in two places. 1.2.0 now stands in
every tracked place that claims the repo's version. No bump, no tag, no new capability.

[skip-docs]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-20 13:12:15 +02:00
commit cec9b8fe50
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 116 additions and 68 deletions

View file

@ -32,12 +32,20 @@ at all, and this release gives each of them a command of its own.
report share one module entry point without a subcommand that tells them apart.
### Security
- A `--run-id` that carries a path is refused by all seven outbox doors before anything is written.
Each writer composes `<out_dir>/<run_id>-<artefact>.json`; the directory was guarded and the run
id was not, so `--outbox-dir <d>/inni --run-id ../../X` wrote two levels above the directory the
caller named and answered `0`. Separators, a bare `.` or `..`, an absolute prefix, an embedded
NUL and the empty string are now refused with exit `3`, the reason named, and no directory left
behind. Fixed as a class, because the composition is identical in all seven.
- A caller-supplied **name** that carries a path is refused by all seven outbox doors before
anything is written. Each writer composes `<out_dir>/<name>-<artefact>.json`; the directory was
guarded and the names were not, so `--outbox-dir <d>/inni --run-id ../../X` wrote two levels
above the directory the caller named and answered `0`. Separators, a bare `.` or `..`, an
absolute prefix, an embedded NUL and the empty string are now refused with exit `3`, the reason
named, and no directory left behind. The class is every caller-supplied value that reaches a
file name, not one flag: `write-outbox` composes `<run_id>-<approach_id>`, and
`--approach-id a/../../../X` escaped the same way through the other half of that stem. Both go
through one rule.
- Containment as the last link: a composed artefact path that does not resolve **under** the
output directory is refused, in `outbox` itself, where all eleven compositions are built and
before the directory is created. The string rule above lives in the door, so this one covers the
callers that never pass a door - the run path hands its own `--run-id` straight to these writers
- and the next value someone interpolates into a file name.
### Fixed
- `--stop-reason` being required is now guarded by a test rather than only by prose. The rule is