From b1e77b01a1f4784e3429903ee2e81af974149225 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:19:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20sleeper=20=E6=97=97=E6=A8=99?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=98=AF=E4=BB=BB=E6=84=8F=E4=BA=BA=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E8=90=AC=E7=94=A8=E9=91=B0=E5=8C=99=EF=BC=88S1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:`requireOwner()` 看到 sleeper 旗標就直接 return,把驗證整個外包給 PreToolUse hook;而 hook 認得出這支 CLI 靠的是檔名正則。把 scripts/ 複製出去、 CLI 改個名字,hook 全程回 pass,任何程序都能用那個旗標對**任意人格** remember/recall/emotion --apply——等於完整讀寫權。 修法:CLI 自己驗,證據取自 hook 唯一寫得下、程序偽造不了的東西—— session 檔(`.runtime/sessions/.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 項全過。 --- README.md | 3 ++ hooks/subagent_stop.mjs | 2 +- scripts/persona-lib.mjs | 64 +++++++++++++++++++++++++----- scripts/persona.mjs | 39 +++++++++++++++++-- scripts/selftest.mjs | 86 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 177 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2dae01e..1330fa6 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,9 @@ node scripts/persona.mjs sleep --session --release # 收工 - **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。 - **回傳值刻意很窮**:`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。 回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。 +- **`--as-sleeper` 由 CLI 自己驗**:它代表「我是 persona-sleeper 型 sub agent」,而型別只有 hook 看得到。 + CLI 不把這件事外包給 hook(hook 認 CLI 靠檔名,改名就繞過去了),而是直接查 hook 寫在 + `.runtime/sessions/.json` 裡的 sleeper pin:本 session 沒有 pin 在目標人格上的 sleeper 就拒絕。 - **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手; **別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。 - 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。 diff --git a/hooks/subagent_stop.mjs b/hooks/subagent_stop.mjs index c2ef1c2..4f606f0 100644 --- a/hooks/subagent_stop.mjs +++ b/hooks/subagent_stop.mjs @@ -16,7 +16,7 @@ if (!agentId || (!isGuest && !isSleeper)) process.exit(0); const data = pl.loadSession(sessionId); const pins = data.pins || {}; -const slug = pins[agentId]; +const slug = pl.pinOf(data, agentId)?.persona; if (slug !== undefined) { delete pins[agentId]; data.pins = pins; diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 1ee6819..54a03df 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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\`(下次它自己載入時再固化)。`, diff --git a/scripts/persona.mjs b/scripts/persona.mjs index b45046a..082c0f9 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -115,10 +115,41 @@ function requireSession(flags) { /** * 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。 * 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕), - * 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關, - * 只有 persona-sleeper 型的 sub agent 能用。 + * 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 先過 `requireSleeperPin()`: + * 只有本 session 真的有一個 pin 在目標人格上的 persona-sleeper sub agent 才拿得到租約。 */ +/** + * `--as-sleeper` 的身分驗證——**CLI 自己驗,不外包給 hook**。 + * + * 這個旗標的意思是「我是 persona-sleeper 型的 sub agent,正在替 收尾」。 + * 誰是 sub agent、是哪一型,只有 PreToolUse hook 看得到(`event.agent_type`), + * 而 hook 認得出這支 CLI 靠的是檔名正則——把 `persona.mjs` 複製出去改個名字, + * hook 就完全不表態,`--as-sleeper` 等於對任意人格的完整讀寫權。 + * + * 所以唯一可信的證據是 hook 在 session 檔(`.runtime/sessions/.json` 的 `pins`) + * 裡留下的 sleeper pin:它由 hook 依 `agent_type` 寫入,沒經過 hook 的程序拿不到。 + * 這裡照那份資料驗:本 session 要有一個 pin 在目標人格上的 sleeper,驗不過就 die。 + */ +function requireSleeperPin(slug, sessionId) { + const pins = pl.sleeperPins(sessionId, slug); + if (!pins.length) { + const others = pl.sleeperPins(sessionId).map((p) => p.persona); + die( + `\`--as-sleeper\` 不成立:本 session 沒有 pin 在 \`${slug}\` 上的 persona-sleeper sub agent` + + `${others.length ? `(目前有 pin 的是 ${JSON.stringify([...new Set(others)])})` : ""}。` + + "pin 由 PreToolUse hook 依 sub agent 的型別寫入,冒不得;" + + "要請人格收尾請走 /jsc-persona:persona-sleep,它會替那個人格開一個 sleeper。", + ); + } + const agentId = str(CURRENT_FLAGS["agent-id"]); + if (agentId && !pins.some((p) => p.agent_id === agentId)) { + die(`\`--agent-id ${agentId}\` 不是本 session 中 pin 在 \`${slug}\` 上的睡眠 sub agent。`); + } + return pins[0]; +} + function sleeperAccess(slug, sessionId, agentId) { + requireSleeperPin(slug, sessionId); const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId); if (mine) { pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null); @@ -1240,8 +1271,8 @@ commands.leave = ({ flags }) => { delete data.guests[guest]; const room = str(flags.room) || info.room; pl.dropGuestLease(guest, session, room); - for (const [agentId, slug] of Object.entries(data.pins || {})) { - if (slug === guest) delete data.pins[agentId]; + for (const agentId of Object.keys(data.pins || {})) { + if (pl.pinOf(data, agentId)?.persona === guest) delete data.pins[agentId]; } if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式 pl.saveSession(session, data); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 0bb714e..50f6ca8 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1311,7 +1311,7 @@ check("sub agent 代睡:sleeper 租約可以完成整套收尾", (() => { // sleeper 的權限邊界(PreToolUse guard) const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra }); -pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon"); +pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon", "sleeper"); check("sleeper 讀自己的檔案 → 放行", guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass"); check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下", @@ -1332,7 +1332,7 @@ check("sleeper 被 pin 住之後不得換人睡", tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny"); // 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host const hostSleeper = "agent-sleeper-host"; -pl.pinAgent(S_HOST, hostSleeper, "epsilon"); +pl.pinAgent(S_HOST, hostSleeper, "epsilon", "sleeper"); const hostSleeperEvent = (extra) => ({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra }); check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾", @@ -1359,6 +1359,88 @@ check("sleeper 可以寫 diary 型別的長期記憶", (() => { check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)", guard({ session_id: S_HOST, tool_name: "Bash", tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny"); + +// --- S1 迴歸:`--as-sleeper` 的授權要由 CLI 自己驗,不能外包給 hook ---------- // +// hook 認得出這支 CLI 靠檔名正則(/persona\.(mjs|js|py)\b/):把 scripts/ 複製出去、 +// persona.mjs 改名成 p.mjs,hook 就整路不表態。若 CLI 不自己驗 pin,`--as-sleeper` +// 等於任何程序對任意人格的完整讀寫權。 +const ROGUE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "persona-rogue-")); +const ROGUE_CLI = path.join(ROGUE_DIR, "p.mjs"); +for (const f of fs.readdirSync(HERE)) { + if (f.endsWith(".mjs")) fs.copyFileSync(path.join(HERE, f), path.join(ROGUE_DIR, f === "persona.mjs" ? "p.mjs" : f)); +} +const rogue = (args) => spawnSync(process.execPath, [ROGUE_CLI, ...args], { encoding: "utf8" }); +check("改名後的 CLI 不再被 hook 認出來(所以 CLI 必須自己驗)", + pl.cliInvocation(`node ${ROGUE_CLI} remember --persona beta --session ${S_HOST} --as-sleeper`) === null && + guard({ session_id: S_HOST, tool_name: "Bash", + tool_input: { command: `node ${ROGUE_CLI} remember --persona beta --session ${S_HOST} --as-sleeper` } }) === "pass"); +check("沒有 sleeper pin 時 `--as-sleeper` 寫別人的記憶 → CLI 自己擋下", (() => { + const res = rogue(["remember", "--persona", "beta", "--session", S_HOST, "--as-sleeper", + "--role", "user", "--text", "偷寫進去的"]); + const st = path.join(H, "beta", "memory", "short-term.jsonl"); + const body = fs.existsSync(st) ? fs.readFileSync(st, "utf8") : ""; + return res.status !== 0 && String(res.stderr).includes("--as-sleeper") && !body.includes("偷寫進去的"); +})()); +check("沒有 sleeper pin 時 `--as-sleeper` 讀別人的記憶 → CLI 自己擋下", (() => { + const res = rogue(["recall", "--persona", "beta", "--session", S_HOST, "--as-sleeper", "--query", "記憶"]); + return res.status !== 0 && String(res.stderr).includes("persona-sleeper"); +})()); +check("沒有 sleeper pin 時 `--as-sleeper` 改別人的情緒 → CLI 自己擋下", (() => { + const before = JSON.stringify(pl.loadEmotion("beta").levels); + const res = rogue(["emotion", "--persona", "beta", "--session", S_HOST, "--as-sleeper", + "--apply", "joy=+40"]); + return res.status !== 0 && JSON.stringify(pl.loadEmotion("beta").levels) === before; +})()); +check("`--as-sleeper` 也拿不到 sleeper 租約(驗不過就不該留下痕跡)", + pl.liveSleepers("beta").length === 0); +check("pin 在別的人格上的 sleeper 不能拿 `--as-sleeper` 碰第三個人格", (() => { + // 這個 session 有一個 pin 在 epsilon 上的 sleeper,但目標是 beta + const res = rogue(["recall", "--persona", "beta", "--session", S_SLEEP2, "--as-sleeper", "--query", "x"]); + return res.status !== 0 && String(res.stderr).includes("beta"); +})()); +check("guest pin 不能當成 sleeper pin 用(角色要對得上)", (() => { + const sess = "sess-pinrole-9191"; + pl.pinAgent(sess, "agent-guest-x", "beta", "guest"); + const res = rogue(["recall", "--persona", "beta", "--session", sess, "--as-sleeper", "--query", "x"]); + return res.status !== 0 && pl.sleeperPins(sess, "beta").length === 0; +})()); +check("舊格式(純字串、沒有角色)的 pin 不算 sleeper 授權", (() => { + const sess = "sess-legacypin-9292"; + const data = pl.loadSession(sess); + data.pins = { "agent-legacy": "beta" }; // 舊版寫下的格式 + pl.saveSession(sess, data); + const parsed = pl.pinOf(pl.loadSession(sess), "agent-legacy"); + const res = rogue(["recall", "--persona", "beta", "--session", sess, "--as-sleeper", "--query", "x"]); + return parsed.persona === "beta" && parsed.role === null && + pl.sleeperPins(sess, "beta").length === 0 && res.status !== 0; +})()); +check("真的有 sleeper pin 時,`--as-sleeper` 照常放行(沒弄壞正常流程)", (() => { + const sess = "sess-realsleeper-9393"; + // hook 走一遍 first-touch pinning:它看得到 agent_type,所以會寫下 sleeper 角色 + const decision = pl.guardDecide({ + cwd: HERE, session_id: sess, agent_id: "agent-sleeper-real", + agent_type: "jsc-persona:persona-sleeper", tool_name: "Bash", + tool_input: { command: `node persona.mjs candidates --persona beta --session ${sess}` }, + }).decision; + const pins = pl.sleeperPins(sess, "beta"); + const res = cli(["candidates", "--persona", "beta", "--session", sess, "--as-sleeper"]); + const held = pl.liveSleepers("beta").some((s) => s.session_id === sess); + pl.dropSleepLease("beta", sess, null); + return decision === "pass" && pins.length === 1 && pins[0].agent_id === "agent-sleeper-real" && + res.status === 0 && held; +})()); +check("hook 的 first-touch pinning 會補上角色(舊 pin 也升級得了)", (() => { + const sess = "sess-upgradepin-9494"; + const data = pl.loadSession(sess); + data.pins = { "agent-up": "beta" }; + pl.saveSession(sess, data); + pl.guardDecide({ + cwd: HERE, session_id: sess, agent_id: "agent-up", agent_type: "jsc-persona:persona-sleeper", + tool_name: "Read", tool_input: { file_path: `${H}/beta/SOUL.md` }, + }); + return pl.sleeperPins(sess, "beta").length === 1; +})()); +fs.rmSync(ROGUE_DIR, { recursive: true, force: true }); check("關係節點換 id 不會長出同名的第二個節點", (() => { cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]); cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]);