# M5b task 07: `loopd` neutralises text from outside when it is first logged **Branch:** `m5b` (run `git switch m5b`; `git status --short` must be empty, otherwise stop) **Commit subject:** `loopd: neutralise user messages, tool results and the memory block` ## Read this first: the attack texts The given tests put your model's chat-template tags (the markup strings) into user messages, tool results and the memory block on purpose, and one forges a system turn inside a fetched page. Your model uses these same strings to mark its reasoning and its tool calls. **If you spell one out, even in your thinking, it ends your reasoning or your turn.** So: - **Do not open** the three given test files (`neutralise.rs`, `neutralise_replay.rs`, `support/tags.rs`), and do not work through their cases in your reasoning. You copy them; you never need to read them. Their failure messages name a case and never show a text. - Nothing you write needs a tag. The code calls `neutralise`, which task 03 wrote; it never looks at what a tag says. Do not write a tag anywhere, as tasks 03 to 06 said. ## Goal A tag in a fetched page, a user message or the memory block becomes the model's own token when it reaches the model, so the text can end a tool result and forge a system turn. Task 03 wrote `bxcore::sanitize::neutralise`, which writes each listed tag's angle brackets as `<` and `>`. This task calls it in `loopd` (M5b spec, section 8), **once, where the text is first appended and logged**, so the logged bytes are what every later request replays and the inference contract holds (each request extends the one before, byte for byte): - **the user's message** (the owner's, or another speaker's): the `User` record, in `run_turn`; - **every text appended as a tool result**, after the cap: a tool's result (`recall`'s among them), a denial, a failure, and `loopd`'s own answers (`find_tool`, `call_tool`, `clock`, the repeated-call sentence). They all reach the model as tool messages through the one `LogRecord::ToolResult` append in `run_turn`, so one call there covers them all. A denial's detail echoes the model's arguments and a failure's message comes from the broker or the tool: in a tool message a tag is a control token wherever it came from. A fixed sentence holds no tag, and `neutralise` gives it back unchanged; - **the memory block**, in `Baseline::assemble_with`. `soul.md` is part of the block, and nothing checks it on write. The baseline is written to `0.baseline.json` when the session is created, and every request of the session sends it from there. What is **not** touched: - **The model's own output** (`content`, `reasoning_content`, `tool_calls`): logged and replayed exactly as the server returned it (inference contract). - **Replay.** `baseline::messages` and `Session::open` send the logged bytes as they are. A log written before this task can hold a raw tag; it is replayed unchanged, never rewritten and never neutralised again, because changing an earlier byte of the prompt forces the server to read the whole prompt again. - `system.md`, the owner's system prompt around the block. **The cap comes first** (M5b plan, review focus 2). `cap_result` cuts a result to the cap and sets `truncated`; the cut text is then neutralised. `<` is longer than `<`, so a logged result can be a few bytes over the cap, and `truncated` says only whether the cap cut it. A tag the cut splits is no longer a tag, and is left as it is. | Name | What it is | |---|---| | `bxcore::sanitize::neutralise` | `pub fn neutralise(text: &str) -> std::borrow::Cow<'_, str>` (task 03): `Borrowed` when nothing matches | | `loopd::tools::cap_result` | `pub fn cap_result(text: &str, cap: usize) -> (String, bool)`: unchanged | | `loopd::turn::run_turn` | two appends change (T3, T4) | | `loopd::baseline::Baseline::assemble_with` | the block is neutralised (B3) | `loopd` already depends on `bxcore`; `sanitize` needs no feature. No dependency changes, and `loopd` never enables `bxcore`'s feature `secrets` (the test `no_secrets.rs` checks it). ## Every place `loopd` logs text This is the complete list. Each one is covered by an edit below, or stays as it is. | Place | What is logged | What happens | |---|---|---| | `crates/loopd/src/turn.rs`, `run_turn`, `LogRecord::User` | the user's message | neutralised (T3) | | `crates/loopd/src/turn.rs`, `run_turn`, `LogRecord::Assistant` | the model's output | unchanged | | `crates/loopd/src/turn.rs`, `run_turn`, `LogRecord::Usage` and `CacheLoss` | numbers | unchanged | | `crates/loopd/src/turn.rs`, `run_turn`, `LogRecord::ToolResult` | every tool message | capped, then neutralised (T4) | | `crates/loopd/src/baseline.rs`, `assemble_with` | the memory block, into the baseline | neutralised (B3) | | `crates/loopd/src/session.rs`, `create` | `SessionStart` and the baseline file | unchanged: the baseline is already neutralised | | `crates/loopd/src/baseline.rs`, `messages`; `crates/loopd/src/session.rs`, `open` | replay | unchanged: never neutralise here | ## Files - Copy, from `docs/plans/M5b/files/` (the same path under it): the new tests `crates/loopd/tests/neutralise.rs`, `crates/loopd/tests/neutralise_replay.rs` and `crates/loopd/tests/support/tags.rs` (included by the two tests with `#[path]`; do not add it to `support/mod.rs`). - Modify: `crates/loopd/src/turn.rs` (T1 to T4), `crates/loopd/src/baseline.rs` (B1 to B3), `docs/implementer-log.md`. - Do not touch: `crates/bxcore`, any `Cargo.toml`, any other file of `loopd`, any other crate. ## The edits, exactly Every "before" block is in its file exactly once. Nothing else in these files changes. ### `crates/loopd/src/turn.rs` **T1.** The module comment: the lines ```rust //! One turn: ask, run any tool calls under their limits, and return. //! The loop is bounded every way a runaway can go: a cap on tool iterations, a repeated-call //! detector, the two M2a limits (thinking tokens, a full context), and a tool-result cap. ``` become ```rust //! One turn: ask, run any tool calls under their limits, and return. //! The loop is bounded every way a runaway can go: a cap on tool iterations, a repeated-call //! detector, the two M2a limits (thinking tokens, a full context), and a tool-result cap. //! Text from outside (the user's message, every tool result) is neutralised as it is first //! logged (M5b spec, section 8), so every later request replays the same bytes; the model's own //! output is logged as the server returned it. ``` **T2.** The line ```rust use crate::tools::{Registry, ToolPort, cap_result, denial_message, dispatch}; ``` becomes ```rust use crate::tools::{Registry, ToolPort, cap_result, denial_message, dispatch}; use bxcore::sanitize::neutralise; ``` **T3.** In `run_turn`, the user's message: the lines ```rust time: Timestamp::now(), content: content.to_string(), ``` become ```rust time: Timestamp::now(), content: neutralise(content).into_owned(), ``` **T4.** In `run_turn`, the tool result: the line ```rust let (text, truncated) = cap_result(&text, rt.cfg.r#loop.tool_result_cap); ``` becomes ```rust // Capped first, then neutralised: the logged text may be a few bytes over the cap, // and `truncated` says only whether the cap cut it. Denials and failures take this // path too, since they reach the model as tool messages as well. let (text, truncated) = cap_result(&text, rt.cfg.r#loop.tool_result_cap); let text = neutralise(&text).into_owned(); ``` The `LogRecord::ToolResult` below it takes `content: text` and `truncated` as today, and the `TurnEvent::ToolResult` after it sends the same `truncated`. ### `crates/loopd/src/baseline.rs` **B1.** The line ```rust use crate::tools::Registry; ``` becomes ```rust use crate::tools::Registry; use bxcore::sanitize::neutralise; ``` **B2.** The comment of `assemble_with`: the lines ```rust /// The system prompt trimmed, then a blank line and the memory block `memoryd` rendered (M5a /// spec, section 4), when there is one; the core tool schemas. ``` become ```rust /// The system prompt trimmed, then a blank line and the memory block `memoryd` rendered (M5a /// spec, section 4), when there is one; the core tool schemas. The block is neutralised here /// (M5b spec, section 8): `soul.md` is not checked on write, and the snapshot of the baseline /// is what every request of the session sends. ``` **B3.** In `assemble_with`: the lines ```rust let memory = memory.trim(); if !memory.is_empty() { system.push_str("\n\n"); system.push_str(memory); } ``` become ```rust let memory = neutralise(memory.trim()); if !memory.is_empty() { system.push_str("\n\n"); system.push_str(&memory); } ``` `memory` is now a `Cow`: `is_empty()` and `&memory` reach the `str` inside it. ## Steps - [ ] **1. Copy the given tests.** From the repository root, three commands: ```sh cp docs/plans/M5b/files/crates/loopd/tests/neutralise.rs crates/loopd/tests/ cp docs/plans/M5b/files/crates/loopd/tests/neutralise_replay.rs crates/loopd/tests/ cp docs/plans/M5b/files/crates/loopd/tests/support/tags.rs crates/loopd/tests/support/ ``` - [ ] **2. See them fail.** `cargo test -p loopd --no-fail-fast --test neutralise --test neutralise_replay`: both compile. In `neutralise`, all six tests fail; in `neutralise_replay`, one fails and two pass: | Test | Before this task | |---|---| | `a_user_message_is_neutralised_in_the_log_and_the_request` | fails: `the record in memory: not as expected …` | | `a_tool_result_is_neutralised_in_the_log_and_the_next_request` | fails: `the record, label kept: not as expected …` | | `a_recall_result_is_neutralised` | fails: `the record: not as expected …` | | `denied_and_failed_texts_are_neutralised_too` | fails: `denied: not as expected …` | | `the_cap_comes_first_then_neutralising` | fails: `case 0: not as expected …` | | `a_fetched_page_that_forges_a_system_turn_reaches_the_model_escaped` | fails: `the record: not as expected …` | | `the_memory_block_is_neutralised_when_the_baseline_is_assembled` | fails: `the assembled baseline: not as expected …` | | `the_models_output_is_logged_and_replayed_byte_exact` | passes, before and after | | `an_old_log_with_a_raw_tag_is_replayed_as_it_is` | passes, before and after | Every message ends `(not shown: it may hold a tag)`. - [ ] **3. `turn.rs`.** Make edits T1 to T4. `cargo check -p loopd`: no error. - [ ] **4. `baseline.rs`.** Make edits B1 to B3. `cargo check -p loopd`: no error. - [ ] **5. See them pass.** The command of step 2: `neutralise` 6 passed, `neutralise_replay` 3 passed. Then `cargo test -p loopd`: every suite passes. - [ ] **6. Check.** `grep -rn 'neutralise(' crates/loopd/src` prints exactly three lines: two in `turn.rs` (T3, T4) and one in `baseline.rs` (B3). - [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms. Expected last line: `gate: ok`, with 1042 tests: 1033 before this task, plus 6 in `neutralise.rs` and 3 in `neutralise_replay.rs`. - [ ] **8. Log and commit.** `git status --short` lists `crates/loopd/src/turn.rs`, `crates/loopd/src/baseline.rs` and your log as modified, and the three new test files. Stage with `git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. `Cargo.lock` does not change in this task; the `git add` is harmless. ## Done when - `make gate` prints `gate: ok` with 1042 tests. - `git diff HEAD~1 --stat` lists only `crates/loopd/src/turn.rs`, `crates/loopd/src/baseline.rs`, the three new files under `crates/loopd/tests/`, and `docs/implementer-log.md`. ## Stop and report if - A "before" block of T1 to T4 or B1 to B3 is not in its file exactly as shown, or is there more than once. - Step 2 shows a different result: a test that does not compile, other failing tests, or other messages. - After step 5 a test fails. Do not open the test to find out why, and do not change a test: stop, and give the failing tests' names and their messages. - The fix seems to need a change anywhere else: in `session.rs`, `baseline::messages`, `channel.rs`, `tools.rs`, `bxcore`, or a `Cargo.toml`. It does not; stop and say why you think so. - A test outside `loopd` fails in the gate: check once whether it fails without your change, then stop and report both results. - The gate passes with a count other than 1042.