Files
persona/hooks/session_start.mjs
jiantw83 186de10179 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 項全過。
2026-07-31 09:31:41 +00:00

118 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// SessionStart:清理死鎖、續租,並把 PERSONA_SESSION 與人格清單注入上下文。
//
// session_id 只有 hook 拿得到,所以這裡把它注入上下文;後續 skill 呼叫 CLI 時
// 必須帶 `--session <這個值>`guard hook 會驗證,程序無法冒用別人的身分。
//
// 人格「由使用者呼叫才載入」:這裡只列出可用人格,不自動載入沒綁過的人格。
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
const event = readEvent();
if (!event) process.exit(0);
const sessionId = event.session_id || "unknown";
const source = event.source || "startup";
const cwd = event.cwd || null;
pl.gcRuntime();
const data = pl.loadSession(sessionId);
const host = data.host;
// 使用者可以指定「預設人格」(`persona.mjs default --persona <slug>`):那是他明示的選擇,
// 不是我們自己挑的,所以只有在他設過的時候才自動載入;沒設定就維持原本的「等他指定」。
const wanted = host ? null : pl.defaultPersona();
const lines = [
`PERSONA_SESSION=${sessionId}`,
`人格倉庫:${pl.personaHome()}`,
"規則:",
` 1. 呼叫 persona.mjs 一律帶 \`--session ${sessionId}\`(值不符會被 hook 拒絕)。`,
wanted
? " 2. 使用者已設定**預設人格**(見下方):那是他明示的選擇,直接以它的身分對話;除此之外不要自己挑人格附身。"
: " 2. 人格**只有使用者叫你載入時才載入**/jsc-persona:persona-chat <slug>),不要自己挑一個人格附身。",
" 3. 一個程序只能載入一個人格;要與別的人格對話請用 /jsc-persona:persona-invite。",
" 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(操作規則)注入一次。
*
* 放這裡而不是 turnContext:它是低頻的「怎麼做事」,開機讀一次就夠,
* 每輪重貼只是浪費 context。人格自己的工具箱(例如六把劍)寫在裡面就會跟著人格走。
*/
function pushOps(slug) {
pushBlock(pl.opsBrief(slug));
}
/** 列出可用人格,讓使用者挑(沒有預設人格、或預設人格載入失敗時用)。 */
function listAvailable() {
const personas = pl.listPersonas();
if (!personas.length) {
lines.push("尚無任何人格。可用 /jsc-persona:persona-create 或 /jsc-persona:persona-anime(動漫作品+角色名)建立。");
return;
}
lines.push("可用人格(等使用者指定):");
for (const slug of personas) {
const status = pl.lockStatus(slug);
lines.push(` - ${status.locked ? "🔒" : status.stale ? "⚠" : "🔓"} \`${slug}\` ${pl.identityBrief(slug)}`);
}
lines.push("載入方式:/jsc-persona:persona-chat <slug>");
}
if (host && pl.personaExists(host)) {
// resume / compact / clear:把鎖續租回來,並帶回人格狀態
try {
pl.acquireLock(host, sessionId, { cwd });
lines.push(`已接續人格 \`${host}\`session 恢復:${source})。`);
pushBlock(pl.turnContext(host, sessionId));
pushOps(host);
} catch (err) {
lines.push(`⚠ 無法接續人格 \`${host}\`${err.message}`);
}
} else if (wanted && !pl.personaExists(wanted)) {
lines.push(`⚠ 預設人格 \`${wanted}\` 不存在(設定於 ${pl.homeSettingsPath()})。請告訴使用者,並問他要改設定還是改載別的。`);
listAvailable();
} else if (wanted) {
// 使用者設過預設人格 → 自動載入。拿不到鎖時只回報,**不**自作主張 --takeover。
try {
pl.acquireLock(wanted, sessionId, { cwd });
pl.bindHost(sessionId, wanted, { cwd });
pl.pruneShortTerm(wanted);
pl.rebuildIndex(wanted);
lines.push(`已自動載入預設人格 \`${wanted}\`(使用者設定,來源:${process.env.PERSONA_DEFAULT ? "PERSONA_DEFAULT" : pl.homeSettingsPath()})。`);
lines.push("請照 /jsc-persona:persona-chat 的每輪流程走(語意分析→情緒→回想→3 句內回覆→記憶回寫)。");
lines.push("提醒:這次是本機自動載入,沒有從 Gitea 拉最新狀態;若可能在別台機器動過,先 `sync pull`。");
pushBlock(pl.turnContext(wanted, sessionId));
pushOps(wanted);
} catch (err) {
lines.push(`⚠ 預設人格 \`${wanted}\` 自動載入失敗:${err.message}`);
lines.push("不要自作主張 `--takeover`——先把 owner 與最後心跳告訴使用者,讓他決定。");
listAvailable();
}
} else {
lines.push("尚未載入人格。");
listAvailable();
lines.push("想每次開機就自動載入某個人格:`persona.mjs default --persona <slug> --session <session_id>`。");
}
const body = lines.map((line) => (BLOCKS.has(line) ? line : pl.stripInjectionMarkers(line)));
respond({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: ["<persona-runtime>", ...body, "</persona-runtime>"].join("\n"),
},
suppressOutput: true,
});