fix(sanitizer): strip new runtime-context preface headers from outbound text#72969
fix(sanitizer): strip new runtime-context preface headers from outbound text#72969jhsmith409 wants to merge 2 commits intoopenclaw:mainfrom
Conversation
…nd text
The v2026.4.24 redesign of embedded runtime-context delivery routes the
context as a hidden `openclaw.runtime-context` next-turn custom message
rather than appending it inline to the user prompt. The Pi runtime
(`@mariozechner/pi-coding-agent` v0.70.2 pinned in `package.json`) then
converts that custom message into a `role: "user"` LLM turn so the model
can read it.
The custom-message body starts with two human-readable lines:
OpenClaw runtime context for the immediately preceding user message.
This context is runtime-generated, not user-authored. Keep internal details private.
(or the `OpenClaw runtime event.` variant for runtime events.) Models
that don't strictly follow the "Keep internal details private" line —
notably smaller open-weight models like Qwen3.6-35B served via local
vLLM — echo the preface verbatim into their visible Telegram / WhatsApp
replies. The existing `stripInternalRuntimeContext` only catches the
delimited `<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>` block and the legacy
`OpenClaw runtime context (internal):` header, neither of which match
the new shape emitted by `buildRuntimeContextMessageContent` in
`src/agents/pi-embedded-runner/run/runtime-context-prompt.ts`.
This change extends the same outbound sanitizer with line-anchored
detection of the two new preface headers, mirroring the existing
adjacent fix in openclaw#71847 that strips copied inbound metadata blocks from
user-facing replies. The strip is conservative: it only fires when the
exact header line is followed by an exact privacy-notice line on the
next line, both at line-start. Inline mentions of the header or notice
phrasing are preserved.
The structural fix (changing how the Pi runtime treats
`openclaw.runtime-context` custom messages so the human-readable
preface never reaches the user-role LLM turn) remains the right
long-term solution and is tracked in openclaw#72386. This sanitizer extension
addresses the visible regression in the meantime.
Fixes openclaw#72386
Greptile SummaryExtends Confidence Score: 4/5Safe to merge; both findings are minor edge-case hardening opportunities that don't affect the common-path stripping behaviour. Only P2 findings: a missing end-of-line boundary guard in findRuntimeContextPrefaceEnd (theoretical partial-line leak on unusual model output) and a redundant g flag. Core logic and tests are correct for the stated use case. src/agents/internal-runtime-context.ts — specifically findRuntimeContextPrefaceEnd (lines 196-210) Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 205-209
Comment:
**No end-of-line boundary check after the notice line**
`findRuntimeContextPrefaceEnd` uses a prefix match on the notice line — it returns a valid `blockEnd` even if the notice line is immediately followed by more characters on the same line (no `\n` or end-of-string terminator). If a model echoes something like `"...Keep internal details private. [ack]"`, `blockEnd` lands in the middle of that line and `after` begins with ` [ack]`, which then passes through with no leading-newline to strip — leaking that fragment into the sanitized output.
Adding a boundary check after the notice is the safer approach:
```typescript
if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
return null;
}
const endOfNotice = cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
const charAfter = text.charCodeAt(endOfNotice);
// Must end at EOF or at a newline; reject if extra characters follow on the same line.
if (!isNaN(charAfter) && charAfter !== 0x0a && charAfter !== 0x0d) {
return null;
}
return endOfNotice;
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 243-244
Comment:
**Redundant `g` flag on anchored `replace` patterns**
Both regexes use `^` or `$` without the `m` (multiline) flag, so they can only match once regardless. The `g` flag is inert here and may be misleading to future readers.
```typescript
const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/, "");
const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/, "");
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(sanitizer): strip new runtime-contex..." | Re-trigger Greptile |
| if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !== | ||
| RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) { | ||
| return null; | ||
| } | ||
| return cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length; |
There was a problem hiding this comment.
No end-of-line boundary check after the notice line
findRuntimeContextPrefaceEnd uses a prefix match on the notice line — it returns a valid blockEnd even if the notice line is immediately followed by more characters on the same line (no \n or end-of-string terminator). If a model echoes something like "...Keep internal details private. [ack]", blockEnd lands in the middle of that line and after begins with [ack], which then passes through with no leading-newline to strip — leaking that fragment into the sanitized output.
Adding a boundary check after the notice is the safer approach:
if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
return null;
}
const endOfNotice = cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
const charAfter = text.charCodeAt(endOfNotice);
// Must end at EOF or at a newline; reject if extra characters follow on the same line.
if (!isNaN(charAfter) && charAfter !== 0x0a && charAfter !== 0x0d) {
return null;
}
return endOfNotice;Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 205-209
Comment:
**No end-of-line boundary check after the notice line**
`findRuntimeContextPrefaceEnd` uses a prefix match on the notice line — it returns a valid `blockEnd` even if the notice line is immediately followed by more characters on the same line (no `\n` or end-of-string terminator). If a model echoes something like `"...Keep internal details private. [ack]"`, `blockEnd` lands in the middle of that line and `after` begins with ` [ack]`, which then passes through with no leading-newline to strip — leaking that fragment into the sanitized output.
Adding a boundary check after the notice is the safer approach:
```typescript
if (text.slice(cursor, cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length) !==
RUNTIME_CONTEXT_PREFACE_NOTICE_LINE) {
return null;
}
const endOfNotice = cursor + RUNTIME_CONTEXT_PREFACE_NOTICE_LINE.length;
const charAfter = text.charCodeAt(endOfNotice);
// Must end at EOF or at a newline; reject if extra characters follow on the same line.
if (!isNaN(charAfter) && charAfter !== 0x0a && charAfter !== 0x0d) {
return null;
}
return endOfNotice;
```
How can I resolve this? If you propose a fix, please make it concise.| const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/g, ""); | ||
| const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/g, ""); |
There was a problem hiding this comment.
Redundant
g flag on anchored replace patterns
Both regexes use ^ or $ without the m (multiline) flag, so they can only match once regardless. The g flag is inert here and may be misleading to future readers.
const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/, "");
const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/, "");Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/internal-runtime-context.ts
Line: 243-244
Comment:
**Redundant `g` flag on anchored `replace` patterns**
Both regexes use `^` or `$` without the `m` (multiline) flag, so they can only match once regardless. The `g` flag is inert here and may be misleading to future readers.
```typescript
const before = next.slice(0, headerIdx).replace(/[ \t]*\r?\n+$/, "");
const after = next.slice(blockEnd).replace(/^\r?\n+[ \t]*/, "");
```
How can I resolve this? If you propose a fix, please make it concise.- findRuntimeContextPrefaceEnd now requires the privacy-notice line to end at \n, \r, or end-of-string. Previously the prefix-only match would accept a notice line that had additional content on the same line (e.g. a model echoing "Keep internal details private. [ack]"), and the strip would land mid-line and leak the trailing fragment. - Drop redundant `g` flag from the two anchored `replace` patterns in stripRuntimeContextPreface — without `m` flag the anchors can only match once, and the g flag was misleading. - Add a regression test for the trailing-content-after-notice case. All 11 tests pass.
|
Both Greptile P2 findings addressed in 39fde09:
All 11 tests pass. |
Production validation: sanitizer works mechanically, but exposes a deeper model-quality issueTested this PR on a live OpenClaw v2026.4.25 deployment (Telegram + local vLLM Mechanical result: works as intended. The visible reply no longer contains the preface text. But the user-facing UX is still broken, just shifted one layer over. On a fraction of turns the model's entire assistant text is just the preface verbatim, e.g. (from "assistantTexts": [
"OpenClaw runtime context for the immediately preceding user message.\nThis context is runtime-generated, not user-authored. Keep internal details private."
]After the patched sanitizer strips that to empty, the channel-delivery path reaches a fallback and the user sees:
I also patched the TTS path ( This confirms the structural concern raised in the original PR description. A sanitizer can keep the leak from reaching the channel, but it can't fix the underlying reason the leak shows up: smaller open-weight models (Qwen3.6-35B in our case) are so disoriented by the new Recommendation: I'm fine with this PR landing as a hardening / belt-and-suspenders fix — the sanitizer should catch echoed prefaces regardless of whether the structural fix lands — but it isn't sufficient to restore correct behavior on its own. For our deployment we're rolling back to v2026.4.24 (which uses the inline conversation envelope and doesn't trigger this confusion in the model). |
|
Thanks @jhsmith409. I did not merge this PR as-is because your production validation was right: sanitizer-only hardening prevented the visible echo, but it could still leave the model confused enough to return an empty/fallback reply. I landed a structural fix on
The changelog credits you and references this PR as carried forward. Closing this PR as superseded by the landed main fix. |
Summary
Outbound sanitizer extension that catches the new runtime-context preface headers introduced by the v2026.4.24 / #71761 redesign, so models that ignore the privacy-notice line cannot leak the preface into user-visible replies.
Closes the visible half of #72386 (Telegram + Qwen-class vLLM model echoing the preface verbatim). The structural fix — making the Pi conversion path treat
openclaw.runtime-contextcustom messages as non-user-role context — remains a separate, larger change and is tracked in that issue.What's leaking
buildRuntimeContextMessageContentinsrc/agents/pi-embedded-runner/run/runtime-context-prompt.tsbuilds custom-message bodies that begin with one of two human-readable headers, followed by a privacy-notice line:or:
queueRuntimeContextForNextTurnqueues that body ascustomType: "openclaw.runtime-context",display: false,deliverAs: "nextTurn". The pinned Pi runtime (@mariozechner/pi-coding-agent@0.70.2) converts those custom messages intorole: "user"LLM turns, so the model reads the entire body — including the human-readable preface.Smaller open-weight models do not reliably honor the Keep internal details private line and echo the preface back into their visible reply. Reproduced on v2026.4.24 and confirmed still present in v2026.4.25 (clawsweeper bot review on #72386 walks through the same source path with file:line citations).
Why a sanitizer (and not a structural fix) here
The bot's review on #72386 explicitly recommends the structural fix as the right long-term answer. That requires either:
@mariozechner/pi-coding-agent), orsendCustomMessage, which has wider behavioral implications.In the meantime, the existing
stripInternalRuntimeContextalready handles two prior runtime-context shapes (the delimited<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>block and the legacyOpenClaw runtime context (internal):header). Extending it with the two new headers is the same pattern, single-file, low-risk, and addresses the user-visible regression today. #71847 just shipped a similar outbound-strip for inbound metadata envelopes.Implementation
src/agents/internal-runtime-context.ts:findLineStartIndex,findRuntimeContextPrefaceEndhelpers — explicit substring matching (not regex) so the strip is precise. Line-anchored: the header must start at the beginning of the string or immediately after a newline. The privacy notice must follow on the next line. Tolerates an optional\rbefore the header→notice newline.stripRuntimeContextPrefacewalks each header in turn, removes matched preface blocks, and trims the surrounding whitespace so the remaining text re-flows as a clean two-newline paragraph break.hasRuntimeContextPrefaceparallel detection, exposed viahasInternalRuntimeContext.stripInternalRuntimeContextnow calls the new strip afterstripLegacyInternalRuntimeContext, preserving order/precedence with the existing block + legacy strips.The strip is conservative: an inline prose mention of either header phrase (e.g. "the OpenClaw runtime event. came up earlier") is preserved because the immediately-following line is not the exact privacy notice. Tests cover this case explicitly.
Tests
src/agents/internal-runtime-context.test.tsadds 7 new cases:hasInternalRuntimeContextdetects both headers when paired with the notice; rejects header-only and inline mentionsAll 10 tests in the file pass (3 pre-existing + 7 new).
Test plan
vitest run src/agents/internal-runtime-context.test.ts— 10 / 10 passbuildRuntimeContextMessageContentshape🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com