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
+84 -2
View File
@@ -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.mjshook 就整路不表態。若 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"]);