fix(security): 人格檔案不能逸出注入區塊、room 台詞不能偽造成系統訊息(S6)

注入到上下文的東西夾在 persona-runtime / persona-context / persona-ops 三種區塊
中間,而夾進去的內容有**不可信來源**:persona-anime 從 Fandom 抓設定寫進
IDENTITY/AGENTS、`sync pull` 從另一台機器拉、`import` 吃外部 bundle、
guest 的 room 台詞是別的人格寫的。原本這些地方**沒有任何跳脫**:

- AGENTS.md 裡放一行結束標記 → opsBrief 的區塊提早關閉,後面的內容跑到區塊外,
  連外層的 runtime 區塊都能一起關掉。
- IDENTITY.md 的 `Vibe:` 欄位值同理,經 identityBrief 進 turnContext。
- room 台詞塞換行 → roomScript 是一行一句「emoji 名字(情緒):內容」,
  於是可以偽造成別人的台詞或系統訊息。

修法:

- 新增 `stripInjectionMarkers()`:把 `<persona-…` 的 `<` 換成全形。內容還讀得懂
  (人格自己寫的說明不會被吃掉),但它不再是一個標籤。
- 新增 `injectSafeLine()`:中和標記 + 換行壓成空白(比照短期記憶的作法)。
- 一個收口勝過十幾個防點:`turnContext()` 與 SessionStart 的 runtime 區塊都改成
  **組完之後對整個內文**做一次,再補上真正的標記;只有 turnContext/opsBrief
  這種自己已處理過、帶合法巢狀標記的整塊原樣保留。
- 讀出來就中和的:`opsBrief()` 的 AGENTS.md 全文(先截斷再中和,長度上限才算得準)、
  `identityFields()` 的欄位值(identityBrief/roomScript/roomDisplayName 全吃這一份)、
  `relationsBrief()` 的人名與備註。
- room:`roomPost()` 在**寫入端**就把 text/emotion/barge_in 壓成一行,
  `roomScript()` 與 `room read` 在**顯示端**再壓一次(舊逐字稿是原文寫進去的)。

測試:新增 14 項——AGENTS.md 與 IDENTITY 欄位的逸出、turnContext 與 SessionStart
的區塊只被關閉一次、短期記憶與關係圖人名走同一個收口、room 台詞的換行偽造與
標記逸出、舊逐字稿的顯示端防線、顯示名不夾帶標記。
反向驗證:把兩個中和函式改成 identity,這 14 項全數失敗。
368 → 383 項全過。
This commit is contained in:
2026-07-31 09:31:41 +00:00
parent 7549c0b69f
commit 186de10179
5 changed files with 189 additions and 20 deletions
+15 -7
View File
@@ -25,7 +25,6 @@ const host = data.host;
const wanted = host ? null : pl.defaultPersona();
const lines = [
"<persona-runtime>",
`PERSONA_SESSION=${sessionId}`,
`人格倉庫:${pl.personaHome()}`,
"規則:",
@@ -37,6 +36,16 @@ const lines = [
" 4. 禁止直接讀寫非當前人格的目錄,hook 會擋下(跨人格資料隔離)。",
];
// `<persona-runtime>` 的內文一樣夾著人格檔案的內容(身分欄位、鎖的 cwd、錯誤訊息),
// 任何一行出現 `</persona-runtime>` 都能把整個區塊關掉。所以組完之後一律中和,
// 只有 turnContextopsBrief 這種「自己已經處理過內文、而且帶合法巢狀標記」的整塊原樣保留。
const BLOCKS = new Set();
function pushBlock(text) {
if (!text) return;
BLOCKS.add(text);
lines.push(text);
}
/**
* 把 AGENTS.md(操作規則)注入一次。
*
@@ -44,8 +53,7 @@ const lines = [
* 每輪重貼只是浪費 context。人格自己的工具箱(例如六把劍)寫在裡面就會跟著人格走。
*/
function pushOps(slug) {
const ops = pl.opsBrief(slug);
if (ops) lines.push(ops);
pushBlock(pl.opsBrief(slug));
}
/** 列出可用人格,讓使用者挑(沒有預設人格、或預設人格載入失敗時用)。 */
@@ -68,7 +76,7 @@ if (host && pl.personaExists(host)) {
try {
pl.acquireLock(host, sessionId, { cwd });
lines.push(`已接續人格 \`${host}\`session 恢復:${source})。`);
lines.push(pl.turnContext(host, sessionId));
pushBlock(pl.turnContext(host, sessionId));
pushOps(host);
} catch (err) {
lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`);
@@ -86,7 +94,7 @@ if (host && pl.personaExists(host)) {
lines.push(`已自動載入預設人格 \`${wanted}\`(使用者設定,來源:${process.env.PERSONA_DEFAULT ? "PERSONA_DEFAULT" : pl.homeSettingsPath()})。`);
lines.push("請照 /jsc-persona:persona-chat 的每輪流程走(語意分析→情緒→回想→3 句內回覆→記憶回寫)。");
lines.push("提醒:這次是本機自動載入,沒有從 Gitea 拉最新狀態;若可能在別台機器動過,先 `sync pull`。");
lines.push(pl.turnContext(wanted, sessionId));
pushBlock(pl.turnContext(wanted, sessionId));
pushOps(wanted);
} catch (err) {
lines.push(`⚠ 預設人格 \`${wanted}\` 自動載入失敗:${err.message}`);
@@ -98,12 +106,12 @@ if (host && pl.personaExists(host)) {
listAvailable();
lines.push("想每次開機就自動載入某個人格:`persona.mjs default --persona <slug> --session <session_id>`。");
}
lines.push("</persona-runtime>");
const body = lines.map((line) => (BLOCKS.has(line) ? line : pl.stripInjectionMarkers(line)));
respond({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: lines.join("\n"),
additionalContext: ["<persona-runtime>", ...body, "</persona-runtime>"].join("\n"),
},
suppressOutput: true,
});