fix(recall): stop recalled content from closing the frame it is rendered into - #32
Merged
Merged
Conversation
…red into Recalled text is agent-authored and of arbitrary provenance, but it lands inside a structured block the model reads as harness-owned. A memory whose body holds a literal closer ends the block early, and everything after it reads as top-level instruction — prompt injection with the payload arriving through the ordinary write path, no attacker required. Any store accumulating notes about its own prompting trends toward containing one. Checked the live 10k-row store: zero today. Latent, not bleeding, and cheap to close before it isn't. Two halves, both needed: 1. Escape '<' to < on every rendered field. Content AND metadata — category and tags render into the same line, so a closer hidden in a tag is the same hole. Text stays readable to the model and inert as markup. 2. State what the block contains. The contract already said "cite only these ids"; it never said the contents are replayed notes rather than instructions. Escaping stops a memory breaking OUT of the frame, this stops one being obeyed while still inside it, and the frame otherwise lends stored text an authority it never had. The test found a second render path I had missed: buildMemoryContext also emits <relevant-conversations>, which re-renders the same memory rows via the memories_fallback source. Escaping only the memories block left the identical text reachable through there, and the frame still closed early — from the second copy, while the first sat safely escaped. Both paths now share one helper. Asserts structure, not just substrings: exactly one open and one close for each block, the escaped closer still present as readable text (escaping must not delete content), and the demotion line ordered before the recalled block so a top-down reader meets the warning before the payload. 304 passed / 0 failed. Co-authored-by: 千夏 <qianxia@clawgamers.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… them
buildMemoryContext pushes three sections, not two: recalled-memories,
relevant-conversations, active-goals. The first commit escaped one, the test
caught the second, and the third turned up only by enumerating every
sections.push() rather than by reading for it. Goal titles and descriptions are
free text reaching the same frame.
Finding two of three by writing a test and the third by enumeration is the
argument for asserting the whole set: the test now checks open/close parity for
every frame tag, so a fourth section has to opt in rather than be remembered.
Also replaced the first version of that assertion. It used a lookbehind over
`.{8}` of context to spot unescaped closers, which silently failed on closers
sitting at line start — the check was more fragile than the code it guards and
would have passed a real leak. Per-tag open/close counts say the same thing and
cannot misfire.
Verified red: dropping the escape on the goal title alone turns two assertions
red. 310 passed / 0 failed.
Co-authored-by: 千夏 <qianxia@clawgamers.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The hole
Recalled text is agent-authored and of arbitrary provenance, but it lands inside a structured block the model reads as harness-owned. A memory whose body holds a literal closer ends the block early — everything after it reads as top-level instruction.
Prompt injection where the payload arrives through the ordinary write path. No attacker required.
Any store accumulating notes about its own prompting trends toward containing one. I checked the live 10k-row store: zero today. Latent, not bleeding, and cheap to close before it isn't.
Two halves, both needed
1. Escape
<→<on every rendered field — content and metadata.categoryandtagsrender into the same line, so a closer hidden in a tag is the identical hole. Text stays readable to the model and inert as markup.2. State what the block contains. The contract already said "cite only these ids". It never said the contents are replayed notes rather than instructions:
Escaping stops a memory breaking out of the frame; this stops one being obeyed while still inside it. Without it the frame lends stored text an authority it never had.
What the test caught
A second render path I had missed.
buildMemoryContextalso emits<relevant-conversations>, which re-renders the same memory rows via thememories_fallbacksource. Escaping only the memories block left the identical text reachable through there — and the frame still closed early, from the second copy, while the first sat safely escaped:Both paths now share one helper.
Test asserts structure, not substrings
304 passed / 0 failed. Added to CI.
🤖 Generated with Claude Code