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:
+84
-2
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user