fix(contracts): summarize the goal, not the injection preamble - #494
Open
ProtocolWarden wants to merge 1 commit into
Open
fix(contracts): summarize the goal, not the injection preamble#494ProtocolWarden wants to merge 1 commit into
ProtocolWarden wants to merge 1 commit into
Conversation
wrap_untrusted_goal emits GOAL_PREAMBLE before the fence, so every issue-sourced goal_text starts with "SECURITY: the text inside the <<UNTRUSTED:...". cxrp_mapper sliced that raw string for two short fields — title=goal_text[:80] and scope=goal_text[:120] — so every issue-sourced task was titled and scoped with the preamble instead of its actual request. Visible live on PRs #478 and #483, whose titles both read "SECURITY: the text inside the <<UNTRUSTED:...>> ... fen" while their real goals were "Fix edge_cases to forward the sample list, not the count dict" and "Add regression test suite that execs the live STEP 3 snippet against the OUTPUT". Both call sites were the same bug; fixing only the title would have left scope broken. injection.py owns the fence format, so the reader lives there rather than as a regex in the mapper: unfence_goal() extracts the payload (backreferenced nonce, so a forged close marker with a guessed nonce does not terminate the span) and goal_summary() unfences, collapses to one line, defangs, and bounds. Two deliberate decisions: - objective still carries the FULL wrapped text. The preamble and fence must reach the executor intact; only the short human/telemetry-facing fields are summarized. A test pins that distinction. - This MOVES attacker-influenced text into GitHub PR titles, which the old accidental behavior did not. goal_summary therefore routes through sanitize_for_comment to defang @mentions and strip zero-width/bidi chars, and collapses newlines, which would otherwise break a title. Verified by mutation: reverting both call sites to the raw slices makes the new pins fail, reproducing the observed string (scope == 'SECURITY: th...from an exter'); restored, all pass. 44 tests across test_injection.py and test_cxrp_mapper.py. No pre-existing test asserts on CxRP title/scope. ruff check and format clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProtocolWarden
force-pushed
the
fix/goal-summary-title
branch
from
August 4, 2026 20:58
f065f30 to
44916ee
Compare
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.
Problem
wrap_untrusted_goalemitsGOAL_PREAMBLEbefore the fence, so every issue-sourcedgoal_textbegins with"SECURITY: the text inside the <<UNTRUSTED:...".cxrp_mapperthen sliced that raw string for two short fields:So every issue-sourced task was titled and scoped with the preamble's opening words instead of its actual request.
Visible live on two open PRs, both titled
SECURITY: the text inside the <<UNTRUSTED:...>> … <</UNTRUSTED:...>> fen:edge_casesto forward the sample list, not the count dictCosmetic in effect, corrosive in practice: routine autonomous PRs read as security events, and the board becomes unscannable. Both call sites are the same bug — fixing only the title would have left
scopebroken.Change
The fix is not a regex in the mapper.
injection.pyowns the fence format, so the reader lives there:unfence_goal()— extracts the fenced payload. The closing marker is matched by backreference on the nonce, mirroringfence's guarantee that a forged close marker with a guessed nonce does not terminate the span. Falls back to the input unchanged when unfenced.goal_summary()— unfence → collapse to one line →sanitize_for_comment→ bound tomax_len.The mapper just calls
goal_summary. The 80/120 bounds are preserved (the CxRP schemas impose no length constraint on these fields — verified).Two deliberate decisions
objectivestill carries the FULL wrapped text. The preamble and fence must reach the executor intact; only the short human/telemetry-facing fields are summarized.test_task_proposal_objective_keeps_the_full_fenced_goalpins that distinction so a future "cleanup" cannot strip the fence from the executor's input.This moves attacker-influenced text into GitHub PR titles, which the old (accidental) behaviour did not do.
goal_summarytherefore routes throughsanitize_for_commentto defang@mentions— a bare@handlein a PR title pings a real person — and strip zero-width/bidi characters. Single-line collapse matters for the same reason: a newline breaks a title.Verification
Verified by mutation, not just green tests. Reverting both call sites to the raw slices makes the new pins fail, reproducing the exact observed string:
Restored, all pass.
test_injection.py, 5 intest_cxrp_mapper.py)title/scope, so blast radius is limited to the new pinsruff check+ruff formatclean; Custodian pre-push gate cleanNote
This does not change what the executor receives, only what humans and telemetry see. #478 and #483 keep their current titles — GitHub titles are set at creation; this fixes titles generated from here on.
🤖 Generated with Claude Code