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>
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 istools/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"; nounsafe; no async runtime; no new dependency.- Roles depend on
bxcoreonly;bxcoredepends on no workspace crate (spec 3). - The
secretsmodule is behind the Cargo featuresecrets;loopdnever 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-Byline 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.
- 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).
- 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 thattruncatedcomes from the cap alone. - 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.rsholds such notes. - 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. - A lock file that cannot be created (an unwritable
run/, orinferproxy'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}, featuresecrets;loadandread_checkedtake the role's name. - Tasks 03 to 05, in
bxcore::sanitize:TAGS: &[Tag](each with its text and token id) andneutralise(text: &str) -> Cow<'_, str>;refuse(text: &str) -> Option<Refusal>,Refusal { rule, sentence, at }(atis a byte offset; the first rule only);normalise(text: &str) -> Stringandflag(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>, withLockError::Held,LockError::IoandLockError::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
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.