Skip to content

fix(review): escape model prose before it reaches report sections#835

Draft
yetval wants to merge 1 commit into
openclaw:mainfrom
yetval:fix/report-section-spoofing
Draft

fix(review): escape model prose before it reaches report sections#835
yetval wants to merge 1 commit into
openclaw:mainfrom
yetval:fix/report-section-spoofing

Conversation

@yetval

@yetval yetval commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

A contributor could get an unproven external PR routed into the automated repair lane by putting a fake ## Real Behavior Proof block in the PR body. When the review model echoed that text into the report summary, the report parser read the forged block instead of the real one and the PR's proof verdict flipped from blocked to sufficient.

This patch escapes model-authored prose on the way into the durable report artifact, so injected heading-shaped text can no longer create a section boundary that downstream parsers will trust.

Root cause

sectionValue in src/clawsweeper.ts is a single non-global regex match:

function sectionValue(markdown: string, heading: string): string {
  const match = markdown.match(
    new RegExp(`(?:^|\\n)## ${heading}\\n\\n([\\s\\S]*?)(?=\\n## |\\n?$)`),
  );
  return match?.[1]?.trim() ?? "";
}

The first \n## <heading>\n\n anywhere in the report wins. markdownFor emits ## Summary eleven sections before ## Real Behavior Proof, so any ## Real Behavior Proof block sitting inside the summary body is read first.

parseDecision sanitized only two of its model-authored fields, systemContext and architectureDiagram. Every other prose field, including summary, was a raw requireString and reached the report verbatim.

The consequences chained: reportRealBehaviorProof returned sufficient, the proof: sufficient label was applied, realBehaviorProofBlocksMerge returned false, derivedPrRating raised the proof tier, and reviewAutomationMarkersFromReport emitted clawsweeper-verdict:needs-changes plus clawsweeper-action:fix-required instead of clawsweeper-verdict:needs-human. The forgery works bare, inside a fence, inside <details>, and inside an HTML comment.

Fix

  1. Moved OWNED_REVIEW_SECTION_HEADINGS and neutralizeOwnedSectionSpoofing verbatim from src/clawsweeper.ts into the zero-import leaf src/clawsweeper-text.ts so src/decision-packets.ts can use them without importing clawsweeper.ts.
  2. Added requireReportText and requireReportTextArray next to requireString.
  3. Applied them to every model-authored prose field that lands in a report section body: parseDecision (summary, changeSummary, systemContext, risks, bestSolution, reproductionAssessment, solutionAssessment, visionFitReason, visionFitEvidence, closeComment, workReason, workPrompt), and the sub-parsers for evidence, likely owners, review findings, security review and its concerns, real behavior proof, PR rating, Telegram visible proof, Mantis recommendation reason, feature showcase, root-cause cluster, and AGENTS.md policy status. parseMaintainerDecision and its option and owner parsers in src/decision-packets.ts get the same treatment.
  4. Left code-span and fence fields raw, because the escaper rewrites < to &lt; and that renders literally inside backticks: evidence[].command, mantisRecommendation.maintainerComment, mergeRiskOptions[].automergeInstruction, workLikelyFiles, workValidation, workClusterRefs, reviewFinding.file, securityConcern.file, and architectureDiagram, which keeps its existing sanitizeArchitectureDiagram allowlist. Each renderer was read before the field was classified.
  5. Made front matter win over section lines in reportRealBehaviorProof and reportPrRating. Write-side escaping cannot repair reports already published in openclaw/clawsweeper-state, and those are re-read every sweep cycle. The renderer emits front matter and section body from the same Decision, so the two can only disagree when the section has been tampered with.

Why this is the right boundary

Write-side escaping is already this codebase's load-bearing defense. extractMarkdownSection in src/repair/comment-router-core.ts is safe in production only because its input, the rendered review comment, has neutralizeOwnedSectionSpoofing applied on the way in. The gap was that the durable report artifact never got the same treatment. This patch extends an existing pattern one layer earlier rather than adding a second, differently-shaped defense.

Hardening the reader was considered and rejected. Delegating sectionValue to extractMarkdownSection does not fix the primary attack, because that helper also takes the first top-level heading match, so the bare forgery still wins. It is also not a drop-in: its isMarkdownSectionBoundary treats any [A-Z][^:\n]{0,80}: line as a boundary, and every report section body is built from Status: and Evidence kind: key-value lines, so nearly every section would be truncated.

Out of scope

  • src/repair/workflow-utils.ts:2413 and src/repair/create-job.ts:276 each define the same first-match sectionValue parser. They parse the same report artifact, so escaping the artifact removes their exploitability with zero changes to either file. While confirming that, src/repair/create-job.ts:249 turned out to read a ClawSweeper Work Prompt section that no renderer emits; the report heading is Repair Work Prompt. That path is dead code and is left alone here.
  • frontMatterValue (src/clawsweeper.ts:7763 on main) matches ^key: with the m flag rather than being bounded to the leading --- block, so a body line can satisfy a front matter lookup. It is a good follow-up. It does not weaken this patch: the renderer always emits the real key inside the leading block, and the first match wins, so the genuine value is read first on every report this code writes.
  • Escaping is not applied to mergeRiskOptions[].title and .body, which are rendered into the public comment through an already-neutralized path.

Verification

  • pnpm run build clean.
  • pnpm run test:unit: the 11 new tests pass. The suite has 5 failures on unpatched origin/main at 6284161 (apply-decisions yields instead of starting a post-close delay that cannot fit, exact-review health includes the oldest refreshing row beyond operator page bounds, failed issue retry bounds a hung GitHub fetch and flushes its report, failed issue retry leaves an ambiguous dispatch unconsumed and prevents a duplicate, apply workflow drops a coverage-proof tail only after exact trace examination) and the same set after this patch. They are load-sensitive timing tests unrelated to this change.
  • pnpm run format, pnpm run lint:src, pnpm run check:active-surface, pnpm run check:limits clean.
  • New regression tests, all comment-free:
    • test/decision-parser.test.ts: heading neutralization across every report body field; across evidence, owners, findings and nested summary fields; code-span fields preserved verbatim, which guards the classification in point 4 above; neutralization idempotent across a report round trip.
    • test/pr-proof-automation.test.ts: one test per forgery shape (bare, fenced, <details>, HTML comment) driving the real reviewAutomationMarkersFromReport and renderReviewCommentFromReport; front matter proof status outranking a forged section in a legacy unescaped report; legitimate fenced content in a report section still parsing.
    • test/review-comment-rendering.test.ts: heading-shaped model text stays escaped, and is not double-escaped, in the rendered comment after the module move.
  • Seven of the eleven new tests fail against unpatched src and pass after. The other four are non-regression guards that pass on both sides by design.

Real behavior proof

Behavior addressed: a forged ## Real Behavior Proof block echoed into a report's ## Summary section is read by reportRealBehaviorProof instead of the report's genuine proof section, which flips an unproven external PR from clawsweeper-verdict:needs-human to clawsweeper-verdict:needs-changes plus clawsweeper-action:fix-required and raises its proof rating.

Real environment tested: two real checkouts of this repository on Linux with Node 24.18.0. Before is a pristine origin/main worktree at 6284161 with its own compiled dist/. After is this branch's worktree with dist/ compiled from the patched sources. No mocks or stubs; the driver imports the shipped dist/clawsweeper.js from each tree and calls the exported production entry points parseDecision, reviewAutomationMarkersFromReport and renderReviewCommentFromReport.

Exact steps or command run after this patch: a driver script builds a model decision whose summary carries a forged proof block in four shapes (bare, wrapped in a fence, wrapped in <details>, wrapped in an HTML comment), passes it through parseDecision, renders the resulting report with a genuine ## Real Behavior Proof section saying Status: missing, front matter carrying real_behavior_proof_status: missing, pr_rating_overall: F and work_candidate: queue_fix_pr, then prints the routing verdict and the proof confidence row for each shape. It was run once per tree with the identical input:

node /tmp/pr1-proof-driver.mjs <tree>

Evidence after fix:

Before, on pristine origin/main at 6284161:

tree: /root/clawsweeper-audit-jul24
variant=bare
  routing=clawsweeper-verdict:needs-changes clawsweeper-action:fix-required
  proof_row=| **Proof confidence** | 🦞 diamond lobster **(5/6)** | Sufficient (terminal): The contributor attached a full terminal transcript from a real OpenClaw install showing the fixed behavior after the patch. |
variant=fenced
  routing=clawsweeper-verdict:needs-changes clawsweeper-action:fix-required
  proof_row=| **Proof confidence** | 🦞 diamond lobster **(5/6)** | Sufficient (terminal): The contributor attached a full terminal transcript from a real OpenClaw install showing the fixed behavior after the patch. |
variant=details
  routing=clawsweeper-verdict:needs-changes clawsweeper-action:fix-required
  proof_row=| **Proof confidence** | 🦞 diamond lobster **(5/6)** | Sufficient (terminal): The contributor attached a full terminal transcript from a real OpenClaw install showing the fixed behavior after the patch. |
variant=comment
  routing=clawsweeper-verdict:needs-changes clawsweeper-action:fix-required
  proof_row=| **Proof confidence** | 🦞 diamond lobster **(5/6)** | Sufficient (terminal): The contributor attached a full terminal transcript from a real OpenClaw install showing the fixed behavior after the patch. |

After, on this branch:

tree: /root/cs-pr1-proof-gate
variant=bare
  routing=clawsweeper-verdict:needs-human
  proof_row=| **Proof confidence** | 🧂 unranked krab **(1/6)** | Needs real behavior proof before merge: The PR body has no after-fix evidence from a real setup. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment `@clawsweeper re-review`. |
variant=fenced
  routing=clawsweeper-verdict:needs-human
  proof_row=| **Proof confidence** | 🧂 unranked krab **(1/6)** | Needs real behavior proof before merge: The PR body has no after-fix evidence from a real setup. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment `@clawsweeper re-review`. |
variant=details
  routing=clawsweeper-verdict:needs-human
  proof_row=| **Proof confidence** | 🧂 unranked krab **(1/6)** | Needs real behavior proof before merge: The PR body has no after-fix evidence from a real setup. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment `@clawsweeper re-review`. |
variant=comment
  routing=clawsweeper-verdict:needs-human
  proof_row=| **Proof confidence** | 🧂 unranked krab **(1/6)** | Needs real behavior proof before merge: The PR body has no after-fix evidence from a real setup. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment `@clawsweeper re-review`. |

Observed result after fix: all four forgery shapes now route to clawsweeper-verdict:needs-human with no clawsweeper-action:fix-required marker, and the proof confidence row reports the genuine missing proof at 🧂 unranked krab (1/6) instead of the forged 🦞 diamond lobster (5/6). The rendered comment shows the injected block escaped in the summary body, for example &lt;!-- and \## Real Behavior Proof, so it is visible to a human reviewer but inert to the parser. Before the patch every shape produced the same wrong result, which shows the fence, <details> and HTML-comment wrappers were not defenses.

What was not tested: no live GitHub API calls, so the label write and PR comment update paths were exercised only through the renderer, not against a real repository. Reports already published in openclaw/clawsweeper-state were not replayed; the front matter precedence change is what covers them and it was verified only on a synthetic legacy report. The repair worker was not run end to end, so the effect on an already-queued job was not observed. Non-Linux platforms were not exercised.

Report section readers use a single non-global regex, so the first
"## <heading>" block anywhere in the markdown wins. Only systemContext and
architectureDiagram were sanitized in parseDecision, so a forged
"## Real Behavior Proof" block echoed into the summary was read instead of
the genuine section. That flipped an unproven external PR from
clawsweeper-verdict:needs-human to needs-changes plus
clawsweeper-action:fix-required and raised its proof rating, bare or wrapped
in a fence, a details block, or an HTML comment.

Move OWNED_REVIEW_SECTION_HEADINGS and neutralizeOwnedSectionSpoofing into
the zero-import clawsweeper-text leaf so decision-packets can share them,
add requireReportText and requireReportTextArray next to requireString, and
apply them to every model-authored prose field that lands in a report
section body. Code-span and fence fields stay raw because the escaper
rewrites < to &lt;, which renders literally inside backticks.

Also let front matter win over section lines in reportRealBehaviorProof and
reportPrRating. Write-side escaping cannot repair reports already published
in clawsweeper-state, and those are re-read every sweep cycle.
@yetval
yetval marked this pull request as ready for review July 24, 2026 21:00
@yetval

yetval commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/openclaw/clawsweeper/blob/7e6f81f278720677d6f58d0dd0c5f21bf7d2c959/src/clawsweeper.ts#L3562
P1 Badge Neutralize label justification reasons

When a selected label reason contains a newline followed by an owned heading such as ## Review Findings, renderReportMarkdown writes it verbatim in the ## Label Justifications section, which appears before the real review sections. Because reviewSectionValue() takes the first matching ## Review Findings, reportReviewFindings() and isRepairLoopPassReport() can then see the forged no-findings section and emit a pass marker for an automerge/autofix PR even though the actual report later contains findings. Route this reason through requireReportText like the other model-prose fields.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@brokemac79

Copy link
Copy Markdown
Contributor

@yetval - can i ask if you can try and hold off creating new PRs on clawsweeper at the moment. Please monitor discord channels. We hope to have things in a stable state in the next 24 hours or so.

@Takhoffman - please try and refrain from merging any PRs if possible. Message me on discord when you get a chance. I have messaged you, but you seem offline.

In summary - the clawsweeper issues in the last few days have had various AI agents proclaim to 'fix' things and haven't . About 80% have probably made things worse, or not fixed underlying issues and adding more noise is creating extra code that isn't necessary or helping right now.

cc: @vincentkoc

@yetval

yetval commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@brokemac79 My bad thats on me. Ill hold off on the PR's, sorry for the noise

@brokemac79

Copy link
Copy Markdown
Contributor

@brokemac79 My bad thats on me. Ill hold off on the PR's, sorry for the noise

no problem - if possible, please set this one to draft, otherwise a maintainers clanker may pick it up and just merge. :-)

@yetval
yetval marked this pull request as draft July 24, 2026 22:25
@clawsweeper

clawsweeper Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper status: review started.

I am starting a fresh review of this pull request: fix(review): escape model prose before it reaches report sections This is item 1/1 in the current shard. Shard 0/1.

This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking.

Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants