注入到上下文的東西夾在 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 項全過。
118 lines
5.4 KiB
JavaScript
118 lines
5.4 KiB
JavaScript
#!/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>` 都能把整個區塊關掉。所以組完之後一律中和,
|
||
// 只有 turnContext/opsBrief 這種「自己已經處理過內文、而且帶合法巢狀標記」的整塊原樣保留。
|
||
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,
|
||
});
|