From ef154b5950d8231c9a43f38444418ec833b7cdc2 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Fri, 10 Apr 2026 14:06:17 +0800 Subject: [PATCH] Fix hidden-message leakage into plugin prompts --- host/st-context.js | 5 +++- index.js | 6 +++-- maintenance/chat-history.js | 42 ++++++++++++++++++++++++++++++++++ retrieval/recall-controller.js | 3 ++- tests/chat-history.mjs | 20 ++++++++++++++++ tests/recall-hide-bypass.mjs | 33 ++++++++++++++++++++++++++ tests/st-context-task-ejs.mjs | 16 +++++++++++++ 7 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/recall-hide-bypass.mjs diff --git a/host/st-context.js b/host/st-context.js index 6e38c72..f6c1060 100644 --- a/host/st-context.js +++ b/host/st-context.js @@ -2,6 +2,7 @@ // 为 prompt 变量扩展(Phase 2)提供统一的 ST 上下文数据接口 import { getContext } from "../../../../extensions.js"; +import { buildPluginVisibleChatMessages } from "../maintenance/chat-history.js"; function safeClone(value, fallback) { if (value == null) { @@ -62,7 +63,9 @@ function resolveLastUserMessage(chat = []) { function buildStructuredSnapshot(ctx = {}) { const char = resolveCharacter(ctx); - const chat = Array.isArray(ctx.chat) ? safeClone(ctx.chat, []) : []; + const chat = Array.isArray(ctx.chat) + ? buildPluginVisibleChatMessages(ctx.chat) + : []; const currentTime = new Date().toLocaleString("zh-CN"); const globalVars = safeClone( ctx.extensionSettings?.variables?.global || {}, diff --git a/index.js b/index.js index b7a69d3..97a3df5 100644 --- a/index.js +++ b/index.js @@ -8615,7 +8615,7 @@ function getLatestUserChatMessage(chat) { for (let index = chat.length - 1; index >= 0; index--) { const message = chat[index]; - if (message?.is_system) continue; + if (isSystemMessageForExtraction(message, { index, chat })) continue; if (message?.is_user) return message; } @@ -8627,7 +8627,9 @@ function getLastNonSystemChatMessage(chat) { for (let index = chat.length - 1; index >= 0; index--) { const message = chat[index]; - if (!message?.is_system) return message; + if (!isSystemMessageForExtraction(message, { index, chat })) { + return message; + } } return null; diff --git a/maintenance/chat-history.js b/maintenance/chat-history.js index 744991c..d52eec2 100644 --- a/maintenance/chat-history.js +++ b/maintenance/chat-history.js @@ -26,6 +26,48 @@ export function isBmeManagedHiddenMessage( ); } +function cloneChatMessageForPluginView(message) { + if (!message || typeof message !== "object") { + return message; + } + + try { + if (typeof structuredClone === "function") { + return structuredClone(message); + } + } catch { + // ignore and fall back to JSON clone + } + + try { + return JSON.parse(JSON.stringify(message)); + } catch { + return { + ...message, + extra: + message.extra && typeof message.extra === "object" + ? { ...message.extra } + : message.extra, + }; + } +} + +export function buildPluginVisibleChatMessages(chat = []) { + if (!Array.isArray(chat)) return []; + + return chat.map((message, index) => { + const cloned = cloneChatMessageForPluginView(message); + if ( + cloned && + typeof cloned === "object" && + isBmeManagedHiddenMessage(message, { index, chat }) + ) { + cloned.is_system = false; + } + return cloned; + }); +} + export function isSystemMessageForExtraction( message, { index = null, chat = null } = {}, diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index c2f190c..b7f292b 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -1,6 +1,7 @@ // ST-BME: 召回输入解析与注入控制器(纯函数) import { debugLog } from "../runtime/debug-logging.js"; +import { isSystemMessageForExtraction } from "../maintenance/chat-history.js"; export function buildRecallRecentMessagesController( chat, @@ -17,7 +18,7 @@ export function buildRecallRecentMessagesController( index-- ) { const message = chat[index]; - if (message?.is_system) continue; + if (isSystemMessageForExtraction(message, { index, chat })) continue; recentMessages.unshift(runtime.formatRecallContextLine(message)); } diff --git a/tests/chat-history.mjs b/tests/chat-history.mjs index 9f565fd..48ecfa9 100644 --- a/tests/chat-history.mjs +++ b/tests/chat-history.mjs @@ -5,6 +5,7 @@ import { resetHideState, } from "../ui/hide-engine.js"; import { + buildPluginVisibleChatMessages, buildExtractionMessages, getAssistantTurns, isAssistantChatMessage, @@ -36,6 +37,25 @@ const realSystemMessage = { }; assert.equal(isSystemMessageForExtraction(realSystemMessage), true); assert.equal(isAssistantChatMessage(realSystemMessage), false); +const pluginVisibleChat = buildPluginVisibleChatMessages([ + realSystemMessage, + managedHiddenAssistant, +]); +assert.equal( + pluginVisibleChat[0].is_system, + true, + "real system message should remain system in plugin-visible chat", +); +assert.equal( + pluginVisibleChat[1].is_system, + false, + "BME-managed hidden message should be restored for plugin-internal chat views", +); +assert.equal( + managedHiddenAssistant.is_system, + true, + "plugin-visible chat clone must not mutate original managed hidden message", +); function createRuntime(chat, chatId = "chat-a") { return { diff --git a/tests/recall-hide-bypass.mjs b/tests/recall-hide-bypass.mjs new file mode 100644 index 0000000..6748c21 --- /dev/null +++ b/tests/recall-hide-bypass.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; + +import { buildRecallRecentMessagesController } from "../retrieval/recall-controller.js"; + +const chat = [ + { is_user: false, is_system: true, mes: "greeting/system" }, + { + is_user: false, + is_system: true, + mes: "managed hidden assistant", + extra: { __st_bme_hide_managed: true }, + }, + { is_user: true, is_system: false, mes: "user message" }, + { is_user: false, is_system: true, mes: "real system" }, + { is_user: false, is_system: false, mes: "visible assistant" }, +]; + +const recentMessages = buildRecallRecentMessagesController(chat, 6, "", { + formatRecallContextLine(message) { + return `[${message.is_user ? "user" : "assistant"}]: ${message.mes}`; + }, + normalizeRecallInputText(value = "") { + return String(value || "").trim(); + }, +}); + +assert.deepEqual(recentMessages, [ + "[assistant]: managed hidden assistant", + "[user]: user message", + "[assistant]: visible assistant", +]); + +console.log("recall-hide-bypass tests passed"); diff --git a/tests/st-context-task-ejs.mjs b/tests/st-context-task-ejs.mjs index 9cb80ce..87c094a 100644 --- a/tests/st-context-task-ejs.mjs +++ b/tests/st-context-task-ejs.mjs @@ -69,6 +69,14 @@ try { }, chat: [ { is_user: true, mes: "第一句" }, + { + is_user: false, + is_system: true, + mes: "被 BME 隐藏的助手楼层", + extra: { + __st_bme_hide_managed: true, + }, + }, { is_user: false, mes: "回应", @@ -115,6 +123,14 @@ try { assert.equal(hostSnapshot.snapshot.variables.local.location, "library"); assert.equal(hostSnapshot.snapshot.chat.lastUserMessage, "最后一句"); assert.equal(hostSnapshot.snapshot.chat.id, "chat-from-global"); + assert.equal( + hostSnapshot.snapshot.chat.messages[1]?.is_system, + false, + ); + assert.equal( + hostSnapshot.snapshot.chat.messages[1]?.mes, + "被 BME 隐藏的助手楼层", + ); assert.equal(hostSnapshot.prompt.charName, "Alice"); assert.equal(hostSnapshot.prompt.userPersona, "桥接 persona");