The task table follows the files (03 tags, 04 refuse, 05 flag) and gives each task's gate count and mutations; the interfaces are the ones the tasks built (task 02's role argument and SecretSpec, Refusal.at, Flag, TurnDone.untrusted required, disarm, LockError::line); the review focus names the tasks that pin each item; which task adds each runbook entry, and the -taskNN convention for replaced given copies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
128 lines
8.5 KiB
Markdown
128 lines
8.5 KiB
Markdown
# M5b implementation plan: hardening
|
|
|
|
> For the implementer: each task file is one closed task for one fresh session. Read `AGENTS.md`,
|
|
> then the task file, and do exactly that task. The driver is `tools/run-plan.sh`.
|
|
|
|
**Goal:** Boxmaker refuses, neutralises or flags hostile text on every path into the model and into
|
|
memory, keeps its secrets in one shared store, explains refused tool calls, disarms links in
|
|
replies from untrusted sessions, and never runs two copies of a daemon.
|
|
|
|
**Architecture:** `proto` becomes `bxcore`, the one crate every role may depend on. It gains
|
|
`secrets` (moved from `gatewayd`) and `sanitize` (refuse, neutralise, flag). The roles call it:
|
|
`memoryd` refuses and flags memory writes, `loopd` neutralises tags when text is first logged and
|
|
checks the tag list against the tokenizer at startup, `gatewayd` escapes images and disarms links,
|
|
`bxctl` explains a text. Each daemon holds a lock file for its life.
|
|
|
|
**Tech stack:** Rust 1.95, edition 2024, the standard library (`File::try_lock` for the locks); no
|
|
new crate.
|
|
|
|
**Spec:** `docs/specs/2026-09-26-m5b-hardening.md` (approved 2026-09-26). Section numbers below are
|
|
the spec's.
|
|
|
|
## Global constraints
|
|
|
|
- `rust-version = "1.95"`; no `unsafe`; no async runtime; no new dependency.
|
|
- Roles depend on `bxcore` only; `bxcore` depends on no workspace crate (spec 3).
|
|
- The `secrets` module is behind the Cargo feature `secrets`; `loopd` never enables it (spec 5).
|
|
- No file the implementer reads holds a chat-template tag whole; tags are built from pieces
|
|
(`concat!`) and named in words (T33).
|
|
- Every path a given test creates is removed by a guard (T44).
|
|
- Rules and lists are compiled in; no rules file (spec 6.5).
|
|
- Every new fail-closed state has a runbook entry and a `see docs/runbook.md#…` pointer.
|
|
- Commit trailer: the `Co-Authored-By` line the driver gives; gate with a 600000 ms timeout.
|
|
|
|
## Review focus
|
|
|
|
Inputs the spec implies but no single task's tests pin down; each line names the task whose tests
|
|
add it.
|
|
|
|
1. **Tags in other shapes**: a listed tag in upper case, split by a zero-width character, or inside
|
|
a longer word. Task 03 measured each shape against the tokenizer and pins it: inside a word the
|
|
tag is still one token and is neutralised; upper case, zero-width, spaced or full-width forms
|
|
are ordinary tokens. Memory refuses the zero-width split as invisible (task 04).
|
|
2. **Neutralising grows text past a cap**: `<` is longer than `<`, so a tool result capped at its
|
|
limit and then neutralised is a few bytes over. Task 07 pins the order (cap, then neutralise) and
|
|
that `truncated` comes from the cap alone.
|
|
3. **Non-Latin text and the homoglyph map**: a note written in Russian or Greek must not be flagged
|
|
because its letters fold to Latin. Task 05's `sanitize_flag_edges.rs` holds such notes.
|
|
4. **Links already in code, and Markdown links**: in an untrusted reply, a URL already inside a code
|
|
span or fence is not wrapped twice, and `[text](url)` is disarmed as a whole. Task 10 pins both.
|
|
5. **A lock file that cannot be created** (an unwritable `run/`, or `inferproxy`'s socket directory
|
|
read-only): the daemon does not start and names the error. Task 12 pins it.
|
|
|
|
## Tasks
|
|
|
|
| # | File | Delivers | Crates | Gate after | Mutations |
|
|
|---|---|---|---|---|---|
|
|
| 00 | (the design model, before the run) | `proto` → `bxcore`, compiler-driven; `AGENTS.md`, `CLAUDE.md`, `docs/dependencies.md` and the brief updated | all | 944 | done: `2b3ae08`, `9672cb5`, `6798502` |
|
|
| 01 | `01-invalid-arguments-detail.md` | `ToolResponse::Denied.detail`; `brokerd` keeps the `ArgsError`; `loopd` appends it | bxcore, brokerd, loopd | 956 | 20 of 21 |
|
|
| 02 | `02-secrets-move.md` | `bxcore::secrets` behind the `secrets` feature; `gatewayd` uses it | bxcore, gatewayd, loopd (a guard test) | 963 | 23 |
|
|
| 03 | `03-sanitize-tags.md` | the 33 tags with their token ids (from the tokenizer, built from pieces), `sanitize::neutralise`, the measured shapes | bxcore | 981 | 16 |
|
|
| 04 | `04-sanitize-refuse.md` | `sanitize::refuse`: control, invisible, private use, noncharacters, known-prefix secrets (PGP private keys too), and tags | bxcore | 997 | 40 |
|
|
| 05 | `05-sanitize-flag.md` | `sanitize::normalise` and `sanitize::flag`: seven rules from Hermes (MIT, `d0288be`) and M5a's phrases | bxcore | 1023 | 60 |
|
|
| 06 | `06-memoryd-sanitize.md` | `memoryd` refuses and flags through `sanitize`; a flag keeps the text as untrusted notes; the block leaves out refused entries by rule name; `check.rs` goes; three runbook entries | memoryd | 1033 | 26 |
|
|
| 07 | `07-loopd-neutralise.md` | `loopd` neutralises user messages, tool results (after the cap) and the memory block when they are first logged | loopd | 1042 | 14 |
|
|
| 08 | `08-loopd-selftest-tags.md` | the self-test's check "tags tokenize as recorded" (66 `/tokenize` calls), and its runbook entry | loopd | 1050 | 20 |
|
|
| 09 | `09-turn-done-untrusted.md` | `TurnDone.untrusted`, required, from the session log's labels | bxcore, loopd | 1058 | 12 |
|
|
| 10 | `10-gatewayd-links.md` | images always as code; URLs as code with the notice when untrusted | gatewayd | 1081 | 71 |
|
|
| 11 | `11-bxctl-sanitize-check.md` | `bxctl sanitize check` | bxctl | 1099 | 21 |
|
|
| 12 | `12-instance-lock.md` | `bxcore::instance::lock`; `loopd` and `inferproxy` take it; runbook `already-running` | bxcore, loopd, inferproxy | 1113 | 24 of 25 |
|
|
| 13 | `13-instance-lock-rest.md` | `brokerd`, `memoryd` and `gatewayd` take it | brokerd, memoryd, gatewayd | 1118 | 14 |
|
|
| 14 | `14-runbook.md` | the runbook entry `sanitizer-rules`, and two entries pointed at it | docs | 1118 | none (docs) |
|
|
|
|
Each count is the gate's test total on titan (rustc 1.98.1) at the end of that task's reference.
|
|
The mutations are one-line breaks of the reference, each caught by a given test unless the row
|
|
says otherwise: task 01's missed one and task 12's (`TryLockError::Error` read as `Held`, which no
|
|
test can provoke) are known. Tasks 06 and 10 are the largest: 06 is 23 exact edits, 10 is 16 small
|
|
`todo!()` helpers in a Markdown scanner; watch them in the run.
|
|
|
|
After task 14, the design model runs the device check of spec 13 on straylight and the owner's
|
|
Mattermost, as for M5a.
|
|
|
|
## Interfaces between tasks
|
|
|
|
Names later tasks rely on; each task file gives the full signatures.
|
|
|
|
- Task 01: `bxcore::ToolResponse::Denied { reason: DenyReason, detail: Option<String> }`.
|
|
- Task 02: `bxcore::secrets::{Secret, SecretSource, SecretSpec, load, read_checked}`, feature
|
|
`secrets`; `load` and `read_checked` take the role's name.
|
|
- Tasks 03 to 05, in `bxcore::sanitize`:
|
|
- `TAGS: &[Tag]` (each with its text and token id) and `neutralise(text: &str) -> Cow<'_, str>`;
|
|
- `refuse(text: &str) -> Option<Refusal>`, `Refusal { rule, sentence, at }` (`at` is a byte
|
|
offset; the first rule only);
|
|
- `normalise(text: &str) -> String` and `flag(text: &str) -> Option<Flag>`, `Flag { rule }`.
|
|
- Task 09: `bxcore::TurnDone { …, untrusted: bool }`, required on the wire.
|
|
- Task 10: `gatewayd::links::disarm(text, untrusted) -> Disarmed { text, links }`.
|
|
- Task 12: `bxcore::instance::lock(path: &Path) -> Result<InstanceLock, LockError>`, with
|
|
`LockError::Held`, `LockError::Io` and `LockError::line(role, path)`.
|
|
|
|
Runbook entries, and the task that adds each (the gate fails on a pointer without its entry):
|
|
`memory-write-refused`, `memory-write-flagged` and `memory-entry-excluded` (06),
|
|
`loopd-tags-unexpected` (08), `already-running` (12), `sanitizer-rules` (14).
|
|
|
|
Given copies replaced by a later task keep the earlier version as `<name>-taskNN`, named for the
|
|
task that copies it (`broker.rs-task01`, `serve.rs-task01`).
|
|
|
|
## Before hand-over
|
|
|
|
Every task is worked through `docs/plans/CHECKLIST.md`: a reference replayed through the gate at
|
|
each task's end state on titan's toolchain, in a scratch worktree with its own build directory;
|
|
mutations; given tests walked by hand and grepped against earlier given files; `/tmp` compared
|
|
around a gate run; the reference moved to straylight as a bundle before the run.
|
|
|
|
Facts fetched while writing the tasks: the tag list and token ids from the GGUF on straylight,
|
|
each checked with `/tokenize` (task 03); Hermes' `tools/threat_patterns.py` at `d0288be` and its
|
|
MIT licence (task 05); llama-server's `/tokenize` fields at b10964 (task 08); Mattermost's
|
|
Markdown, from its `marked` fork and server parser (task 10).
|
|
|
|
## Running it
|
|
|
|
```sh
|
|
git switch -c m5b master # after task 00 is committed on master
|
|
OPENCODE_MODEL=titan/ornith-1.5-35b-a3b RESUME=1 \
|
|
SLOTS_URL='http://127.0.0.1:8081/slots?model=ornith-1.5-35b-a3b' \
|
|
tools/run-plan.sh docs/plans/M5b 01
|
|
```
|
|
|
|
`RESUME` and the watchdog are trials on this run (decisions, 2026-09-26); the review judges them.
|