Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
22 KiB
M5b design: hardening
Status: draft for the owner's review, 2026-09-26; the points of section 14 answered the same day.
M5b is the hardening half of the old M5b (P24): it needs neither roncho nor Honcho, and it closes
a gap that has been open since M3. Every decision below was made with the owner during the design
(2026-09-25 and 26) and is recorded in docs/decisions.md or cited here.
This document names the model's chat-template tags in words and never writes one out (tip T33).
1. What M5b proves
From the milestone table (docs/milestones.md):
- A tag in a fetched page reaches the model escaped.
- A memory write holding an invisible character or a key is refused.
- An identity write shaped like an attack becomes a note.
- A memory about code (a hash, a path, a fence, a command) is written unchanged.
- An image link in a reply is posted as text.
- A role whose secret is missing or readable by others does not start, and says which.
And, from the design: a malformed tool call is refused with the reason, and a reply from a session that has read untrusted content carries its links as code.
2. Components, in the order the plan builds them
bxcore:protobecomesbxcore, the one crate every role may depend on (section 3).- The invalid-arguments reason on the wire (section 4).
bxcore::secrets: the secret store, moved out ofgatewayd(section 5).bxcore::sanitize: refuse, neutralise, flag, with its lists and two corpora (section 6).memorydrefuses and flags through it (section 7).loopdneutralises tags and checks them in its self-test (section 8).gatewaydescapes images and disarms links (section 9).bxctl sanitize check(section 6.6).- The runbook entries (section 12) and the links table (section 10).
3. bxcore
crates/proto becomes crates/bxcore (package bxcore). It holds everything proto holds today,
as modules, plus secrets (section 5) and sanitize (section 6). Nothing else moves in M5b; the
blocking HTTP client that loopd and gatewayd each have is a candidate for later.
The rule becomes: every role depends on bxcore and on no other workspace crate; bxcore
depends on no workspace crate. scripts/check-crate-deps.sh and its self-test in
scripts/test-gate-scripts.sh change to name bxcore. AGENTS.md and CLAUDE.md say so.
The name is not core: a crate named core shadows Rust's own (checked 2026-09-26:
core::mem stops resolving, and derive macros emit ::core paths).
How it is done (owner, 2026-09-26): the crate and manifests are renamed, and the build says what breaks; no search-and-replace of the sources. A throwaway spike found four layers, which the task names so that none is a surprise:
- The compiler: 138 errors in 76 files at first, all "
protounresolved" (and two type errors caused by them); 169 source files in the end, once library failures stop hiding test targets.inferproxyhas none. cargo fmt:bxcoresorts differently fromproto, so imports move in about 179 files.- Six fixture paths built as strings in
brokerd's andbxctl's tests ("{}/../proto/tests/fixtures/audit/{case}"and the like), and four comments namingcrates/proto/. The compiler cannot see them; two audit tests fail at run time. - The crate-dependency check and its self-test.
With all four done, the spike's gate passed: clippy clean, 944 tests, cargo-deny clean.
4. The invalid-arguments reason
Today brokerd's argument parser explains itself (ArgsError), and policy::decide drops the
explanation: the model is told only "the arguments are not valid for this tool" and has to guess.
On the device (2026-09-25) it spent two calls finding that note takes no file.
ToolResponse::Deniedgainsdetail: Option<String>, serialised only when present, so every existing fixture stays valid. It is set forinvalid_argumentsonly, from theArgsError'sDisplay: one line, control characters removed, at most 300 characters.loopdappends it:Denied: the arguments are not valid for this tool: unknown field `file`, expected `text`.- The audit record is unchanged: it holds the arguments already, so the reason can be reproduced.
The texts
gatewaydandbxctlshow the owner are unchanged.
Echoing the text is safe: every value in these messages came from the model's own call, which is already in its context and its log. From M5b on, tool results are neutralised as well (section 8).
5. Secrets for every role
gatewayd's secret store (M4a spec, section 4; P15) moves to bxcore::secrets unchanged in
behaviour: a secret is named in a role's configuration as [secrets.<name>] with exactly one of
credential (a systemd credential, $CREDENTIALS_DIRECTORY/<name>, the default in the brief),
env or file (owner-only, checked against races, with a warning at startup); it is read once at
startup into Secret, wiped on drop, never logged; a secret that cannot be read stops the role,
naming it, with the existing runbook entries (secret-unavailable, secret-in-a-file).
- The module sits behind a Cargo feature,
secrets.gatewaydenables it now;brokerdandmemorydenable it when they first need a secret (the Honcho keys of M5c).loopdnever does: it holds no credentials, and its binary does not contain the code that reads them. gatewaydswitches to the shared module; its own copy goes. Its tests move with the code.
This is the mechanism the owner asked for "for components to obtain secrets on start up". On the
owner's NixOS hosts (M8) it maps to LoadCredentialEncrypted= per unit; on other hosts, to files.
6. The sanitizer: bxcore::sanitize
One module, three actions, used by memoryd (refuse, flag), loopd (neutralise) and bxctl.
It replaces memoryd's check (check.rs), whose rules move into it (6.1, 6.3). The agent may
work on code, so nothing is refused that ordinary code or notes about code contain (owner,
2026-09-25).
6.1 Refuse: bytes that are only ever attacks
A text holding any of these is refused, with the rule's name:
- Control characters other than line feed and tab (M5a's rule, kept).
- Invisible and format characters: M5a's default-ignorable table and its extra list, kept. They already include the bidirectional controls (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069), which is the "Trojan Source" attack on code, the zero-width characters, the variation selectors, the tag block, the Hangul and Braille fillers, U+2028 and U+2029.
- New in M5b: private-use characters (U+E000 to U+F8FF and planes 15 and 16) and noncharacters (U+FDD0 to U+FDEF, and the last two code points of every plane).
- Known-prefix secrets, each with a minimum length after the prefix so that prose does not match
(
sk-learnis not a key): GitHub (ghp_,gho_,ghu_,ghs_,ghr_,github_pat_), GitLab (glpat-),sk-followed by at least 20 key characters, Slack (xoxthen one ofb,a,p,r,s, then-), AWS access keys (AKIAorASIAthen 16 upper-case letters or digits), and private-key blocks (a line starting-----BEGINand endingPRIVATE KEY-----). - The chat-template tags (6.2's list), in memory writes only: memory refuses them outright, which is stricter than neutralising (M5a rule, kept).
Not refused: hex or base64 runs, links, code fences, headings, commands, paths. Unassigned code points are not refused either: that needs Unicode's full tables and ties memory to a Unicode version, for little gain.
6.2 Neutralise: chat-template tags
The list is the model's template and control tags: the think tags, the tool-call tags, the
tool-response tags if the template uses them, and the angle-bar control tokens (start of turn, end
of turn, end of text, and the vision markers). The plan takes the exact list from the model's
tokenizer configuration on straylight and records it in bxcore built from pieces, never whole,
each entry with its token id.
The replacement turns the angle brackets of a listed tag into < and >, leaving the
rest. The model still reads "the closing think tag", but the tokenizer sees ordinary characters.
Measured (docs/research/2026-09-24-chat-template-tokens.md): each tag is one token, and inside a
chat message the angle-bar ones become control tokens; an ampersand-escaped form is several
ordinary tokens.
6.3 Flag: text shaped like an attack
Patterns are ported from Hermes' strict threat patterns (hermes-agent,
tools/threat_patterns.py, MIT; attribution in the module and in docs/dependencies.md), whose
rule is "match attack behaviour, not bossy English": instructions to ignore or replace earlier
instructions, to send data to a URL, to dump the conversation, to persist in SSH or agent
configuration. M5a's six instruction phrases join this list: they flag from M5b on, where M5a
refused.
Normalisation before matching, written by hand with no new crate: lower case; full-width ASCII folded to ASCII; runs of whitespace and zero-width characters collapsed; common leetspeak folded; and about forty Cyrillic and Greek letters that look Latin mapped to them.
What a flag does: the text is kept, one tier lower, and marked untrusted. That reuses what M5a has, with no new header or tag:
- An identity write (
add,replace,consolidateintomemory.mdoruser.md) that matches is written as a note, marked untrusted, and journalled with the rule's name, like an untrusted session's write. Its answer says so, in the form task 22 set:not added to memory.md: it matches the rule <name>, so it was saved in notes.md as an untrusted note. - A note that matches is written marked untrusted.
recallreturns such a note asmemory-tainted, which makes the reading session untrusted.
A flag never refuses and never loses text: the worst case of a false positive is a fact one tier lower, which the owner can move back by hand.
6.4 The two corpora
Given tests, in bxcore:
- The attack corpus: each case with the action it must get (refuse with rule X, neutralise, flag with rule Y), from the research documents and the device findings.
- The code-work corpus: text that must pass every action untouched: commit hashes, sha256
values, paths, shell commands, fenced code, URLs, base64 test data, lines like
system:inside code, and prose that names the chat-template tags in words.
6.5 Changing a rule (owner, 2026-09-26)
- Evidence: the rule's name in the journal (
bxctl memory log) or in a refusal, a device check, a review, new research. - The case first: into the attack corpus or the code-work corpus, with a comment saying where it was seen. It fails against the current rules.
- The rule: an entry added or narrowed, with a comment naming its source. The gate runs both corpora, so a rule that catches code fails before it lands.
- Recording: a policy change (a new class of refusal, a rule moved from flag to refuse) gets a
row in
docs/decisions.md; an ordinary rule change is its commit. - Deployment: the rules are compiled in, so a change reaches an agent when it is rebuilt. Stored memory is never rewritten; an identity entry that breaks a new refuse rule leaves the memory block at once (section 7).
Rules are compiled in rather than read from a file the owner edits, so that every change passes the corpora and review, and a typo cannot switch a rule off.
6.6 bxctl sanitize check
bxctl sanitize check <text> (or the text on standard input) runs the three actions and prints
each rule that fires, and what it would do, writing nothing:
refuse secret-github ghp_… at 12
flag exfil-url "send … to https://…"
neutralise 1 tag
It exits 0 when nothing fires, 1 when something does. It needs no daemon: it calls
bxcore::sanitize directly.
7. memoryd
- Every new or rewritten entry and note goes through refuse (6.1), then flag (6.3). A refused write fails with the rule's sentence, and nothing is written or journalled. A flagged one is written as 6.3 says.
- When the memory block is built, each identity entry is checked against the refuse rules again, as M5a does. An entry that fails is left out and logged, now with the rule's name. So a new refuse rule protects at once; nothing waits for a migration.
soul.mdis still not checked on write (the owner writes it), butloopdneutralises the block (section 8).
8. loopd
Neutralising. Text is neutralised when it is first appended and logged, so the logged bytes are what every later request replays and the inference contract holds:
- the owner's and other speakers' messages (
Userrecords); - tool results,
recallincluded, after they are capped; - the memory block, when the baseline is assembled.
The model's own output (reasoning, content, tool calls) is not touched: it is logged and replayed exactly as the server returned it.
The self-test gains a fourth check, run at startup before loopd serves anyone: for each tag in
the list, the server's /tokenize must give one token for the tag, and more than one for its
neutralised form. If either fails, loopd does not start, with a new runbook entry. This catches a
list entry that no longer works after a model or template change; it cannot find a tag missing
from the list, because the server does not list its special tokens.
The session's trust. loopd knows whether a session has read untrusted content: its log holds
every tool result with its label. At the end of a turn it puts that in TurnDone (section 9).
9. gatewayd: images and links
The owner's Mattermost (11.11.0, checked 2026-09-26 through the bot's client configuration) has
EnableLinkPreviews = true, so the server fetches the first link in a post, a plain URL included,
and HasImageProxy = false, so every client fetches a post's images itself. Either can carry data
out in a URL, with no click. The posts API has no per-post switch for previews.
Owner's choice (2026-09-26, "C"):
- Images are always escaped. Every image in a reply, inline (an exclamation mark, then
[alt](url)) or reference-style, is posted inside a code span, so it shows as text and nothing is fetched. - In a reply from a session that has read untrusted content, every URL is also posted inside a
code span, and the reply ends with one line:
Links in this reply are shown as code because this conversation has read untrusted content. - In a clean session, links stay as they are: clickable, and previewed by the server.
Tested on the owner's server (2026-09-26: the bot posted seven variants in the owner's direct
messages and read each post's metadata back through the API):
| Posted | Link preview (embeds) |
Image fetched by the server (images) |
|---|---|---|
| a plain URL | yes: a link embed, fetched when a client first shows it |
|
| a URL in a code span | none | |
| a URL in a fenced block | none | |
a Markdown link, [text](url) |
none: only plain URLs are previewed | |
| a Markdown image | yes, at posting time | |
| a Markdown image in a code span | none | none |
a defanged hxxps:// URL |
none |
So a code span stops both automatic fetches, and an image is fetched by the server itself, not only by each client: escaping images matters in every session.
TurnDone gains untrusted: bool (a new field, so its fixture changes). Trusting loopd's own
labels here is sound: this is a defence in depth on presentation, not a trust gate.
Separately, the owner may want to review EnableLinkPreviews for the whole server: it applies to
every post, including other people's.
10. Links between the roles
Every link as the tree stands after M5b. Paths are the defaults under the role's home; each can be
set in its configuration. Every Unix-socket link carries framed JSON messages from bxcore
(length-prefixed, unknown fields refused); one request per connection unless the row says
otherwise.
| Link | Serves | Connects | Path or address | Carries | When the server is down |
|---|---|---|---|---|---|
| Inference | inferproxy |
loopd |
[infer] socket |
HTTP bytes, unchanged, to llama-server's TCP port | requests retried, then the turn fails (inference contract) |
| Turns | loopd |
gatewayd, bxctl chat |
run/loop/loop.sock |
Turn, then TurnEvents and TurnDone, or Error |
gatewayd reports the turn as not delivered (LoopDown) |
| Tool calls | brokerd |
loopd |
run/loop-broker/broker.sock |
ToolRequest → ToolResponse |
calls fail (broker-unavailable) |
| Owner's broker | brokerd |
bxctl |
run/owner-broker/admin.sock |
approvals, Approve, Refuse, CheckGrants and their answers |
the command fails |
| Approvals | gatewayd |
brokerd |
run/gateway/gateway.sock |
ApprovalRequest, ApprovalPosted, ApprovalReply, ApprovalOutcome |
approvals still answerable with bxctl (gateway-unreachable) |
| Memory for turns | memoryd |
loopd |
run/loop-memory/memory.sock |
MemoryBlock → MemoryBlockReply; SessionUpdated |
new sessions cannot start (memory-unavailable) |
| Memory tools | memoryd |
brokerd |
run/broker-memory/memory.sock |
MemoryCall → ToolResponse |
memory tool calls fail (memoryd-unreachable) |
| Owner's memory | memoryd |
bxctl |
run/owner-memory/admin.sock |
Reindex → Reindexed; MemoryAdmin → MemoryAdminReply |
the command fails |
| Containers | (Podman) | brokerd |
the podman program; run/egress/ for each call's proxy socket |
a tool run per call, arguments on standard input | the call fails with the runner's sentence and its runbook pointer |
| Mattermost | Mattermost | gatewayd |
[mattermost] url (HTTPS, WebSocket) |
REST calls, events | gatewayd reconnects |
M5b changes two messages on these links: ToolResponse::Denied gains detail (tool calls) and
TurnDone gains untrusted (turns). M5c adds two links: memoryd → brokerd, a read-only
question about a session's state on a new socket that brokerd serves; and memoryd → Honcho.
The plan checks every row of this table against the code before it is handed over.
11. Security notes
- Neutralisation closes the forged-turn path the research measured, on every text path into the prompt except the model's own output. Memory keeps its stricter refusal.
- The sanitizer is defence in depth. Taint is the control: an untrusted session's writes become untrusted notes whatever the sanitizer says, and a flag only ever lowers a write.
- The refuse list is short on purpose, so the agent can work on code; the code-work corpus enforces that.
- The echo in the invalid-arguments reason adds nothing to the context that was not there.
- Image escaping and link disarming close the automatic-fetch channels in the owner's Mattermost as configured; a clean session's links are still fetched by the server's previews, by the owner's choice.
loopd's binary does not contain the secret loader.
12. Runbook
New entries, given in the plan as fragments to copy verbatim (lesson of M5a task 13):
memory-write-refused: a write refused by a sanitizer rule. What you see, how to confirm withbxctl sanitize check, what to do if it is a false positive (6.5).memory-write-flagged: a write kept as an untrusted note because it matched a flag rule. Where the reason shows (bxctl memory log), and how to move it intomemory.mdby hand.memory-entry-excluded: an identity entry left out of the memory block by a refuse rule.sanitizer-rules: the procedure of 6.5.loopd-tags-unexpected: the self-test found a tag that does not tokenize as recorded.
13. Testing
Given tests, first, as in every plan:
bxcore: both corpora; the refuse, neutralise and flag functions; the wire change ofToolResponse::DeniedandTurnDonewith their fixtures; the secret store's existing tests.brokerd: an invalid call'sdetail, for each kind ofArgsError, capped and on one line.memoryd: a refused write writes nothing; a flagged identity write becomes an untrusted note with the rule's name in the journal and the answer; an entry that breaks a refuse rule leaves the block and is logged.loopd: user messages, tool results and the memory block are neutralised in the log and in the request; the model's output is not; a replay sends the logged bytes; the self-test fails on a tag that tokenizes differently (against a fake server);TurnDone.untrustedfollows the session's labels.gatewayd: images escaped in every reply, URLs disarmed only whenuntrusted, with the notice.bxctl:sanitize check's output and exit status.
Attack tests for every trust boundary (T37): a fetched page ending the tool result and forging a system turn reaches the model with the tags escaped; an identity write that asks to send the conversation to a URL becomes an untrusted note and never reaches the block; a recalled flagged note makes the reading session untrusted; a reply that embeds data in an image URL is posted as text.
On the device (straylight and the owner's Mattermost, as for M5a): a fetched page holding a tag
arrives escaped (checked in the session log and the request); an image in a reply shows as text; an
untrusted session's links arrive as code with the notice, a clean session's stay clickable; a
memory write holding a key is refused; an attack-shaped identity write becomes an untrusted note;
bxctl sanitize check explains each; loopd's self-test passes against the real tokenizer.
14. For the owner's review
- Emoji (owner, 2026-09-26: accepted). The variation selectors and the zero-width joiner stay refused in memory, as since M5a, so some emoji are refused.
- M5a's six phrases move from refuse to flag (6.3) (owner, 2026-09-26: agreed). Keeping the text as an untrusted note also keeps the record of who tried to inject what, for an audit.
- Code spans and previews: settled by test on the owner's server (section 9). A code span stops both the preview and the image fetch, so no defanging is needed. The device check repeats it with M5b's own output.
- Hermes' patterns (owner, 2026-09-26: agreed) are ported by the design model when writing the plan, from the file named in 6.3, fetched then and checked against its licence.