fix(security): sleeper 旗標不再是任意人格的萬用鑰匙(S1)

問題:`requireOwner()` 看到 sleeper 旗標就直接 return,把驗證整個外包給
PreToolUse hook;而 hook 認得出這支 CLI 靠的是檔名正則。把 scripts/ 複製出去、
CLI 改個名字,hook 全程回 pass,任何程序都能用那個旗標對**任意人格**
remember/recall/emotion --apply——等於完整讀寫權。

修法:CLI 自己驗,證據取自 hook 唯一寫得下、程序偽造不了的東西——
session 檔(`.runtime/sessions/<id>.json`)裡的 sleeper pin。pin 由 hook 依
`event.agent_type` 寫入,沒經過 hook 的程序拿不到。

- `pinAgent()` 多記角色(`{ persona, role, pinned_at }`),`pinOf()` 相容舊的純字串格式;
  舊格式沒有角色,一律不算 sleeper 授權,hook 下次 first-touch 時會補上。
- 新增 `sleeperPins(sessionId, slug)`:本 session 中 pin 在該人格上的 sleeper。
- `requireSleeperPin()` 進 `sleeperAccess()`(requireOwner/requireMember 都會經過):
  沒有對應的 sleeper pin 就 die,連 sleeper 租約都不會留下。
  有帶 `--agent-id` 時額外要求它就是那個被 pin 的 agent。
- 連帶更新讀 pins 的三處:resolveScope、`leave` 的 guest pin 清理、SubagentStop hook。

persona-sleeper sub agent 的正常流程不受影響:hook 的 first-touch pinning 在它
第一個指令就寫好 pin,之後每個收尾指令都驗得過。

測試:新增 10 項(實際把 scripts/ 複製成別的檔名重現攻擊)——改名後 hook 確實
不表態、無 pin 時 remember/recall/emotion 都被 CLI 擋下且不留租約、pin 在別的
人格上不能跨過去、guest pin 與舊格式 pin 都不算授權、真有 pin 時照常放行。
345 → 355 項全過。
This commit is contained in:
2026-07-31 09:19:20 +00:00
parent 9e7a9cf8e2
commit b1e77b01a1
5 changed files with 177 additions and 17 deletions
+54 -10
View File
@@ -2838,7 +2838,8 @@ export function resolveScope(sessionId, agentId, agentType) {
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
if (isSleeperAgent) {
const pinned = (data.pins || {})[agentId || ""];
const pin = pinOf(data, agentId);
const pinned = pin?.persona || undefined;
return {
role: "sleeper",
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
@@ -2847,6 +2848,7 @@ export function resolveScope(sessionId, agentId, agentType) {
host,
guests,
pinned,
pinnedRole: pin?.role ?? null,
rooms: [],
session: data,
};
@@ -2862,7 +2864,8 @@ export function resolveScope(sessionId, agentId, agentType) {
session: data,
};
}
const pinned = (data.pins || {})[agentId || ""];
const pin = pinOf(data, agentId);
const pinned = pin?.persona || undefined;
return {
role: "guest",
allowed: pinned ? [pinned] : guests,
@@ -2870,18 +2873,52 @@ export function resolveScope(sessionId, agentId, agentType) {
host,
guests,
pinned,
pinnedRole: pin?.role ?? null,
rooms: data.rooms || [],
session: data,
};
}
export function pinAgent(sessionId, agentId, slug) {
/**
* 讀出某個 sub agent 的 pin。
*
* 舊格式是純字串(只有人格、沒有角色),新格式是 `{ persona, role, pinned_at }`。
* 角色要記下來的理由:`--as-sleeper` 的授權只能靠 pin,而 pin 是不是 sleeper 開的,
* 只有寫 pin 的當下(hook 看得到 `agent_type`)知道。
*/
export function pinOf(data, agentId) {
const raw = (data?.pins || {})[agentId ?? ""];
if (!raw) return null;
if (typeof raw === "string") return { persona: raw, role: null };
return { persona: raw.persona || null, role: raw.role || null };
}
export function pinAgent(sessionId, agentId, slug, role = null) {
const data = loadSession(sessionId);
data.pins ??= {};
if (data.pins[agentId] !== slug) {
data.pins[agentId] = slug;
saveSession(sessionId, data);
const cur = pinOf(data, agentId);
if (cur && cur.persona === slug && cur.role === role) return;
data.pins[agentId] = { persona: slug, role, pinned_at: nowIso() };
saveSession(sessionId, data);
}
/**
* 本 session 裡「真的存在、而且 pin 在 `slug` 上」的 persona-sleeper sub agent。
*
* 這是 `--as-sleeper` 唯一可信的授權依據:pin 只由 PreToolUse hook 依 `agent_type` 寫入,
* 而 `agent_type` 是任何被啟動的程序自己看不到、也偽造不了的東西。
* CLI 不能改用「hook 有沒有擋我」當證明——hook 認 CLI 靠檔名正則,改個檔名就繞過去了。
*/
export function sleeperPins(sessionId, slug = null) {
const data = loadSession(sessionId);
const out = [];
for (const agentId of Object.keys(data.pins || {})) {
const pin = pinOf(data, agentId);
if (!pin || pin.role !== "sleeper" || !pin.persona) continue;
if (slug && pin.persona !== slug) continue;
out.push({ agent_id: agentId, ...pin });
}
return out;
}
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
@@ -2921,8 +2958,15 @@ export function guardDecide(event) {
);
}
}
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
// pin 同時是 CLI 端 `--as-sleeper` 的授權憑證,所以角色一定要一起寫進去
// (舊格式那種沒有角色的 pin 也在這裡補上)。
{
const target = scope.pinned || info.personas[0];
if (agentId && target && (scope.pinned !== target || scope.pinnedRole !== "sleeper")) {
pinAgent(sessionId, agentId, target, "sleeper");
}
}
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
} else if (scope.role === "guest") {
@@ -3003,7 +3047,7 @@ export function guardDecide(event) {
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
);
}
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
if (agentId && (!scope.allowed || scope.pinnedRole !== "sleeper")) pinAgent(sessionId, agentId, slug, "sleeper");
if (MUTATING_TOOLS.has(tool)) {
return deny(
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI` +
@@ -3030,7 +3074,7 @@ export function guardDecide(event) {
}
// 3) guest 唯讀 + first-touch pinning
if (scope.role === "guest") {
if (!scope.pinned && agentId) pinAgent(sessionId, agentId, slug);
if (agentId && (!scope.pinned || scope.pinnedRole !== "guest")) pinAgent(sessionId, agentId, slug, "guest");
if (MUTATING_TOOLS.has(tool)) {
return deny(
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,