Skip to content

fix: hard-enforce protection of recent messages + last user message - #5

Merged
ranxianglei merged 1 commit into
masterfrom
2026-08-01_protect-recent-messages
Aug 1, 2026
Merged

ranxianglei merged 1 commit into
masterfrom
2026-08-01_protect-recent-messages

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

问题

最近 N 条消息(preserveRecentMessages)和最后一条用户消息的"保护"只在建议层生效,没有硬强制:

  • computeProtectedRefs 算出保护集 → 喂给 recommendNode/nudge → 只决定 nudge 建议哪些范围可压缩
  • 但 applyCompression(模型调 compress 工具的真正执行路径)从不消费这个保护集 —— protectedMessageIds 参数一路传到 applySingleRange 却从未被读取(grep 0 次使用)

后果:模型若无视 nudge 建议,直接调 compress({startId, endId}) 压缩最近 N 条或最后一条用户消息,会成功,把当前任务的活跃上下文压没。

修复

1. applySingleRange 硬强制保护(src/compress.ts)

计算范围直接消息后,把它们转成 ref(保护集存的是 REF id mNNNNN,而 filteredIds 是 RAW id,需经 state.messageRefs.byRaw 转换)逐个测试是否在保护集,命中即抛错并点名具体 ref:

Range includes protected messages (the last 5 messages and/or the most recent user message) which must not be compressed: m00020, m00025. Adjust startId/endId to exclude them.

2. applyCompression 默认安全(src/compress.ts)

调用方没传 protectedMessageIds 时,自动用 computeProtectedRefs(...) 计算,保证任意代码路径都安全——不只记住传参的调用方(如 pai-acp compress-tool)。

3. Rule 3 语义收紧(src/recommend.ts)

"最后一条用户消息始终保护"现跟随 preserveRecentMessages > 0,与 Rule 1 同一开关。原先无条件保护与"设 preserveRecentMessages=0 完全 opt out"的意图冲突。生产 defaultConfig 用 5,实际始终保护;仅影响显式设 0 的场景(单元测试)。

测试

新增 tests/protect-recent.test.ts(5 例):

  • 拒绝压缩最后 N 条
  • 拒绝与 recent 区重叠的范围
  • 拒绝压缩最后一条用户消息(即便它在 N 窗口外,preserveRecentMessages > 0 时)
  • 允许压缩严格在 recent 区之前的消息
  • 不传 protectedMessageIds 时默认保护仍生效

更新了若干既有测试:小数据集 fixtures 压缩尾部的,显式 preserveRecentMessages: 0 opt out;两个原依赖"Rule 3 无条件保护"契约的测试改为 > 0 触发。

验证

  • typecheck ✅
  • 187 tests pass ✅
  • build ✅

安全影响

这是真实的数据安全缺陷修复——理论上模型一个错误/越界的 compress 调用就能吞掉正在进行的任务上下文。修复后,无论模型如何调用 compress,最近 N 条和最后一条用户消息都绝不可能被压缩。

The recent-N / last-user-message protection existed only as advisory:
computeProtectedRefs fed the protected set into recommendNode/nudge to
shape which ranges are SUGGESTED as compressible. But applyCompression
(the actual execution path, invoked by the compress tool) never consumed
that set — the protectedMessageIds param was threaded through applySingleRange
but never read. So a model could ignore the nudge and call compress()
directly on the last N messages or the most recent user message, and it
would succeed, compressing away the active task context.

Two fixes:

1. applySingleRange now HARD-enforces protection: after computing the
   range's direct message ids, it converts them to refs (via
   state.messageRefs.byRaw — the protected set holds REF ids mNNNNN while
   filteredIds holds RAW ids) and rejects the range if any fall in the
   protected zone, throwing a clear error naming the offending refs.

2. applyCompression defaults protectedMessageIds to computeProtectedRefs(...)
   when the caller omits it, so every code path is safe by default — not
   just callers (like pai-acp's compress-tool) that remember to pass it.

Also tightened Rule 3 semantics: the "last visible user message is always
protected" now follows preserveRecentMessages > 0, coupling it to the same
switch as Rule 1. Previously it was unconditional, which conflicted with
tests/intent that set preserveRecentMessages=0 to fully opt out. Production
defaultConfig uses 5, so the last user message remains always-protected in
practice; this only affects callers that explicitly disable recent protection.

Added tests/protect-recent.test.ts (5 cases: last-N refused, overlap refused,
last-user refused even outside window, older range still allowed, default
protection without explicit set). Updated existing tests whose small fixtures
compressed the tail to opt out via preserveRecentMessages:0.

typecheck clean; 187 tests pass; build OK.
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.

1 participant