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:
+63
-12
@@ -2433,8 +2433,10 @@ export function relationsBrief(slug, names = null, limit = 5) {
|
||||
return nodes
|
||||
.map((n) => {
|
||||
const tone = toneFor(n);
|
||||
return `${n.name || n.id}(${n.kind || "human"}/${tone.bond_label}・語氣層 ${tone.layer}` +
|
||||
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `/${n.note}` : ""})`;
|
||||
// 關係圖也有不可信來源(import/sync pull/anime 抓來的原作關係),
|
||||
// 人名與備註都壓成一行並中和標記。
|
||||
return `${injectSafeLine(n.name || n.id)}(${n.kind || "human"}/${tone.bond_label}・語氣層 ${tone.layer}` +
|
||||
`/親近 ${n.closeness ?? "?"}/信任 ${n.trust ?? "?"}${n.note ? `/${injectSafeLine(n.note)}` : ""})`;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
@@ -2475,9 +2477,24 @@ export function joinRoom(room, persona) {
|
||||
/** `--to all`:這句話是對全場說的(發言權開放)。 */
|
||||
export const ROOM_ALL = "all";
|
||||
|
||||
/**
|
||||
* 聊天室發言。
|
||||
*
|
||||
* 台詞是**別的人格**(guest sub agent)寫的,而 `roomScript()` 會把它排成
|
||||
* `emoji 名字(情緒):內容` 一行一句——台詞裡塞換行就能偽造成別人的台詞或系統訊息,
|
||||
* 塞 `</persona-context>` 就能把讀到它的那一輪注入區塊關掉。所以寫入時就壓成一行、
|
||||
* 中和掉標記(比照短期記憶的作法),不要等到顯示的時候才處理。
|
||||
*/
|
||||
export function roomPost(room, speaker, text, { emotion = "", kind = "say", to = null, bargeIn = null } = {}) {
|
||||
const entry = { ts: nowIso(), speaker, kind, text, emotion, to: to || ROOM_ALL };
|
||||
if (bargeIn) entry.barge_in = String(bargeIn).slice(0, 200);
|
||||
const entry = {
|
||||
ts: nowIso(),
|
||||
speaker,
|
||||
kind,
|
||||
text: injectSafeLine(text),
|
||||
emotion: injectSafeLine(emotion),
|
||||
to: to || ROOM_ALL,
|
||||
};
|
||||
if (bargeIn) entry.barge_in = injectSafeLine(bargeIn, 200);
|
||||
appendJsonl(roomTranscript(room), entry);
|
||||
return entry;
|
||||
}
|
||||
@@ -2487,7 +2504,7 @@ export const roomRead = (room, limit = 30) => readJsonl(roomTranscript(room), li
|
||||
/** 這個聊天室裡的顯示名(拿不到身分就用 slug)。 */
|
||||
export function roomDisplayName(slug) {
|
||||
if (!slug || slug === ROOM_ALL) return "全場";
|
||||
return (personaExists(slug) ? identityFields(slug).Name : "") || slug;
|
||||
return injectSafeLine((personaExists(slug) ? identityFields(slug).Name : "") || slug);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2540,17 +2557,20 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
|
||||
// 三人以上才標「對誰講」:只有兩個人的時候那是廢話。
|
||||
const crowded = (meta.members || []).length > 2;
|
||||
const lines = [];
|
||||
// 顯示端再壓一次:`roomPost` 之前寫下的舊逐字稿還是原文,一行一句的排版
|
||||
// 只要有換行就會被讀成別人的台詞。
|
||||
for (const msg of roomRead(room, limit)) {
|
||||
if (msg.kind === "meta" || msg.speaker === "system") {
|
||||
if (includeMeta) lines.push(`(${msg.text})`);
|
||||
if (includeMeta) lines.push(`(${injectSafeLine(msg.text)})`);
|
||||
continue;
|
||||
}
|
||||
const slug = msg.speaker;
|
||||
const ident = personaExists(slug) ? identityFields(slug) : {};
|
||||
const name = ident.Name || slug;
|
||||
const emoji = ident.Emoji ? `${ident.Emoji} ` : "";
|
||||
const name = injectSafeLine(ident.Name || slug);
|
||||
const emoji = ident.Emoji ? `${injectSafeLine(ident.Emoji)} ` : "";
|
||||
const arrow = crowded && msg.to && msg.to !== ROOM_ALL && msg.to !== slug ? ` → ${roomDisplayName(msg.to)}` : "";
|
||||
lines.push(`${emoji}${name}${msg.emotion ? `(${msg.emotion})` : ""}${arrow}:${msg.text}`);
|
||||
const emotion = injectSafeLine(msg.emotion);
|
||||
lines.push(`${emoji}${name}${emotion ? `(${emotion})` : ""}${arrow}:${injectSafeLine(msg.text)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -3118,6 +3138,26 @@ export function guardDecide(event) {
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 給 hook 用的上下文組裝
|
||||
// --------------------------------------------------------------------------- //
|
||||
//
|
||||
// 注入到上下文的東西都夾在 `<persona-ops>` / `<persona-context>` / `<persona-runtime>`
|
||||
// 中間,而夾進去的內容有**不可信來源**:`persona-anime` 從 Fandom 抓設定寫進 IDENTITY/
|
||||
// AGENTS、`sync pull` 從另一台機器拉、`import` 吃外部 bundle、guest 的 room 台詞是別的
|
||||
// 人格寫的。內容裡只要出現一行 `</persona-ops>`,區塊就提早關閉——後面的文字跑到區塊外,
|
||||
// 讀起來就變成「系統在說話」,連外層的 `</persona-runtime>` 都能一起關掉。
|
||||
//
|
||||
// 所以注入前一律把這類標記拆掉。作法是把 `<` 換成全形 `<`:內容還讀得懂
|
||||
// (人格自己寫的說明不會被吃掉),但它不再是一個標籤。
|
||||
|
||||
/** 把 `<persona-*>` / `</persona-*>` 這類注入標記中和掉(`<` → 全形 `<`)。 */
|
||||
export function stripInjectionMarkers(text) {
|
||||
return String(text ?? "").replace(/<(\/?)(persona-[A-Za-z0-9_-]*)/gi, "<$1$2");
|
||||
}
|
||||
|
||||
/** 注入用的單行文字:標記中和 + 換行壓成空白(換行可以偽造成另一個發言者/系統訊息)。 */
|
||||
export function injectSafeLine(text, limit = 0) {
|
||||
const one = stripInjectionMarkers(text).replace(/[\r\n]+/g, " ").trim();
|
||||
return limit > 0 ? one.slice(0, limit) : one;
|
||||
}
|
||||
|
||||
export function identityFields(slug) {
|
||||
const fields = {};
|
||||
@@ -3134,7 +3174,9 @@ export function identityFields(slug) {
|
||||
if (value.startsWith("(") || value.startsWith("_(")) continue;
|
||||
const raw = m[1];
|
||||
const key = /^[A-Za-z]/.test(raw) ? raw[0].toUpperCase() + raw.slice(1).toLowerCase() : raw;
|
||||
fields[key] = value;
|
||||
// IDENTITY.md 有不可信來源(anime 抓來的設定、sync pull、import):欄位值在這裡
|
||||
// 就中和掉,identityBrief/roomScript/roomDisplayName 全都吃這一份,不必各自防。
|
||||
fields[key] = stripInjectionMarkers(value);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
@@ -3160,10 +3202,13 @@ export function opsBrief(slug) {
|
||||
}
|
||||
if (!text) return "";
|
||||
const file = path.join(personaDir(slug), "AGENTS.md");
|
||||
// AGENTS.md 是全文夾進 `<persona-ops>` 的:裡面放一行 `</persona-ops>` 就能提早關閉區塊,
|
||||
// 後面的內容跑到區塊外面。先截斷再中和,長度上限才算得準。
|
||||
let body = text;
|
||||
if (body.length > OPS_BRIEF_MAX_CHARS) {
|
||||
body = body.slice(0, OPS_BRIEF_MAX_CHARS) + `\n\n(後略;全文見 ${file})`;
|
||||
}
|
||||
body = stripInjectionMarkers(body);
|
||||
return [
|
||||
"<persona-ops>",
|
||||
`以下是 \`${slug}\` 的操作規則(${file}):這輪開機注入一次,之後不再重複貼。`,
|
||||
@@ -3335,6 +3380,12 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
}
|
||||
if (inbox.length) lines.push(`⚠ 有 ${inbox.length} 個聊天室 inbox 待消化(guest 期間留下的見聞)。`);
|
||||
}
|
||||
lines.push("</persona-context>");
|
||||
return lines.join("\n");
|
||||
// 一個收口:這裡的每一行都可能夾帶人格檔案的內容(身分欄位、記憶、關係圖的人名、
|
||||
// 長期記憶的第一行……),任何一處出現 `</persona-context>` 都能提早關閉區塊。
|
||||
// 與其在十幾個 push 點各自防,不如把整個內文中和完再補上真正的標記。
|
||||
return [
|
||||
"<persona-context>",
|
||||
stripInjectionMarkers(lines.slice(1).join("\n")),
|
||||
"</persona-context>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user