Skip to content

fix(intel): stop feed newlines forging rows in the three sibling prompt blocks (#5881) - #5897

Merged
koala73 merged 3 commits into
koala73:mainfrom
Yigtwxx:fix/prompt-line-forgery-sibling-modules
Aug 10, 2026
Merged

fix(intel): stop feed newlines forging rows in the three sibling prompt blocks (#5881)#5897
koala73 merged 3 commits into
koala73:mainfrom
Yigtwxx:fix/prompt-line-forgery-sibling-modules

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5881. Follows #5884, whose "Out of scope" section tracks exactly these modules.

sanitizeForPrompt (server/_shared/llm-sanitize.js:79) preserves a lone newline by design — it splits on \n to drop role-prefixed lines, rejoins, then collapses only runs of 2+ whitespace. That is correct for prose bodies and wrong wherever the newline is the delimiter of the block being composed. There, one feed-supplied \n forges an extra row the model reads as a separate, real datum. sanitizeForPromptLine (#5857) is the same sanitizer plus a full whitespace-run collapse; this applies it at the four line-composing sites the sweep left behind.

1. deduct-situation.ts:69 — market titles are composed into - "${title}" — Yes ${pct}% (${vol} volume) rows and joined with \n under a ## Prediction Market Odds (crowd-calibrated) header. A title carrying a newline forges an extra market with an attacker-chosen probability and volume, inside a block the deduction prompt presents as crowd-calibrated evidence.

2. brief-why-matters-prompt.ts:270-278Headline: / Description: / Source: / Severity: / Category: / Country: rows, joined with \n. Fixed in sanitizeStoryFields (:37) rather than at the call site, because api/internal/brief-why-matters.ts:303 feeds the legacy buildWhyMattersUserPrompt (shared/brief-llm-core.js:75) from that same function, and it composes the identical row shape. One fix, both paths. description is the likeliest carrier — it is free-form article body.

3. _country-brief-context.ts:210-213 — the worst of the three. headlineLines interpolated item.title raw, so this block was missing both the delimiter guard and the #3724 content sanitization:

const headlineLines = groundingItems
  .map((item) => (typeof item.title === 'string' ? item.title : ''))
  .filter(Boolean);
const contextSnapshot = [...sourceLines, 'Headlines:', ...headlineLines].join('\n')...

The sibling briefSourceContextLines two functions above is accidentally safe only because JSON.stringify escapes the newline — nothing there is a deliberate guard.

4. scripts/lib/brief-llm.mjs — the site the issue lists as "also worth checking". Same class, different runtime: this is the Railway seeder's prompt builder.

buildDigestPrompt (:508) is the sharpest instance in the whole set. It interpolated s.headline, s.category, s.country and s.source raw into

`${n}. [h:${shortHash}] [${sev}] ${s.headline}${s.category} · ${s.country} · ${s.source}`

and the system prompt asks the model to key its output off the [h:<hash>] token. So a feed newline here does not merely add noise — it mints a numbered row carrying an attacker-chosen story hash into the composed brief. brief-compose.mjs:704 does sanitize these fields upstream, but with sanitizeHeadline / sanitizeForPrompt, both of which keep a lone newline. The delimiter guard has to live where the delimiter is.

buildStoryDescriptionPrompt (:288) is guarded at the composition site too, not only through sanitizeStoryForPrompt. The production caller (:360) does pass sanitized fields, but a builder whose safety depends on its caller is one new call site away from a hole — which is the lesson docs/solutions/conventions/mutate-each-call-site-a-global-mutant-hides-per-site-holes.md records from the #5857 sweep.

What is deliberately left on prose semantics

The Context: line in buildStoryDescriptionPrompt is not line-sanitized. It is the block's one free-prose sink — the article body, whose internal newlines are legitimate grounding text — and #5857's own rule is to keep sanitizeForPrompt on prose. tests/brief-llm.test.mjs:1788 locks that contract explicitly ("locks the contract so a future 'tidy whitespace' change doesn't silently shift behaviour").

That leaves a residual, and I would rather name it than quietly break a test that exists to stop exactly this: Context: is composed into the same newline-joined block as the rows above, so a newline in the body can still forge a trailing Key: value row. Closing it properly means rendering the body under its own structural header instead of as a labelled row — a prompt-shape change, not a sanitizer change, and one that would alter the grounding the description path depends on. A test pins the current behaviour so the residual stays visible rather than reading as covered. Happy to take it in this PR if you would rather close it now.

Verification

tests/prompt-context-line-forgery.test.mjs   11 pass, 0 fail   (new)
tests/seeder-prompt-line-forgery.test.mjs     6 pass, 0 fail   (new)

Every guard is mutation-proven, the same way the #5857 guards were:

Mutant Red
deduct-situation.tssanitizeForPrompt 2
brief-why-matters-prompt.tssanitizeForPrompt 3
_country-brief-context.tssanitizeForPrompt 1
_country-brief-context.ts → no sanitization at all 2
brief-llm.mjs digest headline / category / source → raw 2 each
brief-llm.mjs description headline → raw 2
brief-llm.mjs import aliased back to sanitizeForPrompt 5

No survivors.

The tests assert the row count the payload declares, not the absence of the forged string — a guard that only greps for the payload passes for the wrong reason the moment that string is echoed anywhere. For the digest block the assertion is specifically that no row begins with the forged [h:...] token; the forged text does survive inside its own row, inert, which is the correct outcome. Companion cases pin that ordinary values still render byte-identical (Côte d'Ivoire, an unremarkable market title, an ordinary digest row).

buildPredictionContext is exported so its guard can be driven through the real builder. The existing tests/deduct-situation-edge-budget.test.mjs avoids importing this module and reads its source text instead, but that approach cannot satisfy the issue's mutation criterion — and the module imports cleanly under the test runner, getRedisCredentials-style side effects included, so no harness was needed.

Neighbouring suites, on this branch:

chat-analyst, llm-sanitize, brief-why-matters-analyst,
country-intel-brief-cache-key, deduct-situation-edge-budget,
brief-llm, brief-llm-core, brief-story-context
  + the two new files                          448 pass, 0 fail

Other gates:

npm run typecheck        clean
npm run typecheck:api    clean (incl. audit-convex-string-calls PASS)
npx biome check          clean (6 files)
npm run lint:boundaries  no violations
check-unicode-safety     2605 files scanned, clean
esbuild bundle           api/intelligence/v1/[rpc].ts and
                         api/internal/brief-why-matters.ts both bundle clean

npm run test:data: identical failure set to origin/main — 45 failing test names on both, comm diff empty in both directions (the OpenAPI contract, docs/i18n, pricing and Docker suites already red on a clean checkout).

Out of scope

Type of change

  • Bug fix
  • New feature
  • New data source / feed
  • New map layer
  • Refactor / code cleanup
  • Documentation
  • CI / Build / Infrastructure

Affected areas

  • Map / Globe
  • News panels / RSS feeds
  • AI Insights / World Brief
  • Market Radar / Crypto
  • Desktop app (Tauri)
  • API endpoints (/api/*) — deduct-situation and get-country-intel-brief prompt assembly; no contract or response-shape change
  • Config / Settings
  • Other: scripts/lib/brief-llm.mjs (Railway brief seeder prompt builders)

Checklist

  • Tested on worldmonitor.app variant — N/A. The change is entirely inside LLM prompt assembly; there is no user-visible surface to exercise, and reproducing it in production would require a poisoned upstream feed item. Verified through the prompt builders directly, with mutation proof that each guard has teeth.
  • Tested on tech.worldmonitor.app variant (if applicable) — N/A, no variant-specific behaviour.
  • New RSS feed domains added to api/rss-proxy.js allowlist (if adding feeds) — N/A, no feeds added.
  • No API keys or secrets committed
  • TypeScript compiles without errors (npm run typecheck)

Documentation Alignment Checklist

N/A — this PR does not publish or change any documentation claim. It tightens sanitization inside prompt assembly; no methodology, API/MCP contract, generated doc, Redis key or example changes. Listed for completeness:

  • Claim ledger attached or linked — N/A, no documented claim changes.
  • All required Audit Council role signoffs attached — N/A, no methodology or contract change.
  • Generated docs regenerated from proto where applicable — N/A, no proto change.
  • Fixture-backed examples recomputed — N/A, no published example depends on these prompt blocks.
  • Redis writers/readers enumerated for every documented key — N/A. The only key read here is news:digest:v1:full:en, already an existing read in _country-brief-context.ts; no key is added, removed or written.

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

@Yigtwxx is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the trust:safe Brin: contributor trust score safe label Jul 30, 2026
@Yigtwxx
Yigtwxx force-pushed the fix/prompt-line-forgery-sibling-modules branch from 3136638 to 5b3c5ab Compare August 2, 2026 09:38
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (9d7b8fc0). The branch had fallen 61 commits behind; it replays with no conflicts and the diff is unchanged at +431/-27 across 6 files.

Re-verified on the rebased tree:

  • tests/prompt-context-line-forgery.test.mjs + tests/seeder-prompt-line-forgery.test.mjs — 17 pass, 0 fail
  • Neighbouring suites (chat-analyst, llm-sanitize, brief-why-matters-analyst, country-intel-brief-cache-key, deduct-situation-edge-budget, brief-llm, brief-llm-core) plus the two new files — 448 pass, 0 fail
  • npm run typecheck, npm run typecheck:api (incl. audit-convex-string-calls: PASS), npx biome check, npm run lint:boundaries, check-unicode-safety (2653 files) — all clean
  • Both touched edge entrypoints bundle clean under the CI flags (esbuild --bundle --format=esm --platform=browser): api/intelligence/v1/[rpc].ts and api/internal/brief-why-matters.ts
  • npm run test:data — no failure on this branch that is absent from origin/main. Two tests are order- or time-dependent rather than related to this change, and I would rather name them than round them off: tests/feed-catalog-drift.test.mts fails only under --test-concurrency=16 and passes standalone on both main and this branch, and Product catalog freshness flipped between two runs.

The red Vercel check is Authorization required to deploy — it needs a Team member to authorize deployments for this fork. The gate context is green.

On the overlap with #5895: that PR covers the three TypeScript prompt builders. This one covers those three plus scripts/lib/brief-llm.mjs, the Railway seeder prompt builder the issue lists as also worth checking. buildDigestPrompt interpolates s.headline, s.category, s.country and s.source raw into a row whose [h:...] token the system prompt asks the model to key its output off, so a feed newline there does not merely add noise — it mints a numbered row carrying an attacker-chosen story hash. Every guard in this PR also carries a mutation table recording which assertions go red when it is reverted.

Either way works for me. If you would rather take #5895 for the TypeScript side, I am happy to rebase onto it and reduce this to the seeder half.

@Yigtwxx
Yigtwxx force-pushed the fix/prompt-line-forgery-sibling-modules branch from 5b3c5ab to 707b235 Compare August 8, 2026 06:54
@Yigtwxx

Yigtwxx commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (06066aff1) — the branch had fallen 141 commits behind. It replays with no conflicts and the diff is unchanged at +431/-27 across 6 files.

Re-verified on the rebased tree: npx tsx --test tests/prompt-context-line-forgery.test.mjs tests/seeder-prompt-line-forgery.test.mjs — 17 pass, 0 fail. No file this PR touches was modified upstream in those 141 commits.

One thing worth flagging for triage: #5895, which covered the same issue, was closed on 3 Aug. This is now the only open fix for #5881.

Yigtwxx and others added 3 commits August 10, 2026 10:12
…locks (koala73#5881)

sanitizeForPrompt preserves a lone newline by design: it splits on the newline
to drop role-prefixed lines, rejoins, then collapses only runs of 2+ whitespace.
That is right for prose bodies and wrong wherever the newline is the delimiter
of the block being composed, where one feed-supplied newline forges an extra row
the model reads as a separate, real datum. koala73#5857 closed this in
chat-analyst-context.ts; these three siblings were declared out of scope there.

- deduct-situation.ts: market titles are composed into `- "..."` rows under
  a "## Prediction Market Odds" header. A title can forge an extra market with
  an attacker-chosen probability and volume.
- brief-why-matters-prompt.ts: fixed at sanitizeStoryFields rather than at the
  call site, because api/internal/brief-why-matters.ts:303 feeds the legacy
  buildWhyMattersUserPrompt from the same function and composes the same
  `Label: value` rows. description is the likeliest carrier -- free-form
  article body.
- _country-brief-context.ts: the worst of the three. headlineLines interpolated
  item.title raw, so this block was missing both the delimiter guard and the
  koala73#3724 content sanitization. The sibling source lines are accidentally safe
  only because JSON.stringify escapes the newline.

buildPredictionContext is exported so the guard can be driven through the real
builder; the module imports cleanly, so no source-text harness is needed.

Each guard is mutation-proven: neutering sanitizeForPromptLine back to
sanitizeForPrompt turns 2, 3 and 1 cases red respectively, and dropping
_country-brief-context's sanitization entirely turns 2 red. The tests assert the
row count the payload declares rather than the absence of the payload string,
which would pass for the wrong reason as soon as the string is echoed anywhere.
Companion cases pin that ordinary values still render byte-identical.
The fourth site koala73#5881 flags. Same defect class, different runtime:
scripts/lib/brief-llm.mjs is the Railway seeder's prompt builder, and both of
its blocks compose rows and join them with a newline.

buildDigestPrompt is the sharper of the two. It interpolated s.headline,
s.category, s.country and s.source raw into
`NN. [h:<hash>] [SEV] headline — category · country · source` rows, and the
system prompt asks the model to key its output off the [h:<hash>] token. So a
feed newline does not just add noise: it mints a numbered row carrying an
attacker-chosen hash into the composed brief.

buildStoryDescriptionPrompt is guarded at the composition site, not only
through sanitizeStoryForPrompt. The production caller does pass sanitized
fields, but a builder whose safety depends on its caller is one new call site
away from a hole -- which is the lesson
docs/solutions/conventions/mutate-each-call-site-a-global-mutant-hides-per-site-holes.md
records from the koala73#5857 sweep.

The Context: line is deliberately left on plain prose semantics. It is the
block's one free-prose sink -- the article body, whose internal newlines are
legitimate grounding text -- and tests/brief-llm.test.mjs:1788 locks that
contract on purpose. That leaves a residual: Context sits in the same
newline-joined block, so a body newline can still forge a trailing `Key: value`
row. Closing it means rendering the body under its own structural header, a
prompt-shape change rather than a sanitizer fix, so it is flagged rather than
done silently. A test pins the current behaviour so the residual stays visible.

Mutation-proven: reverting any of the six guards, or aliasing the import back to
sanitizeForPrompt, turns the suite red -- no survivors.
@koala73
koala73 force-pushed the fix/prompt-line-forgery-sibling-modules branch from 707b235 to 5ae8bc2 Compare August 10, 2026 06:16
@koala73
koala73 enabled auto-merge (squash) August 10, 2026 06:28
@koala73
koala73 merged commit 75dd6cc into koala73:main Aug 10, 2026
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trust:safe Brin: contributor trust score safe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(intel): newline forges prompt rows in three sibling prompt-context modules (out of #5857's scope)

2 participants