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:
+35
-4
@@ -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,正在替 <slug> 收尾」。
|
||||
* 誰是 sub agent、是哪一型,只有 PreToolUse hook 看得到(`event.agent_type`),
|
||||
* 而 hook 認得出這支 CLI 靠的是檔名正則——把 `persona.mjs` 複製出去改個名字,
|
||||
* hook 就完全不表態,`--as-sleeper` 等於對任意人格的完整讀寫權。
|
||||
*
|
||||
* 所以唯一可信的證據是 hook 在 session 檔(`.runtime/sessions/<id>.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);
|
||||
|
||||
Reference in New Issue
Block a user