diff --git a/README.md b/README.md index 1330fa6..14567bf 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ persona/ | **1a. 用與 OpenClaw 相同的描述建立人格** | `persona-create` 逐項索取 `Name` / `Creature` / `Vibe` / `Emoji` / `Avatar`(連括號提示文字都照 OpenClaw 原文),`SOUL.md` 沿用 `Core Truths` / `Boundaries` / `Vibe` / `Continuity` 段落結構 | | **1b. 動漫作品+角色名快速建人格** | `persona-anime` 先上網蒐集該角色的公開設定(至少 3 個獨立來源),映射成上述五欄位與 SOUL,再把設定固化成 `canon` 基礎記憶(每則帶來源 URL)+原作人際關係圖+情緒基線 | | **2. 同一個人格只能被一個程序載入(Sub Agent 不限)** | `state/lock.json` 以 **session_id** 為主鍵、15 分鐘心跳租約;同一 session 的 sub agent 沿用同一把鎖,跨 session 搶佔會被拒;租約過期才可接手(並強制回報) | -| **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路),非當前人格一律 deny;CLI 也驗 `--session` 防止冒用身分 | +| **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路、Glob/Grep 的樣式欄位與 cwd),指向非當前人格就 deny;CLI 也驗 `--session` 防止冒用身分。**這是防漂移的護欄,不是對抗性沙箱**——見〈guard 擋得住什麼、擋不住什麼〉 | | **4. 邀請人格用 Sub Agent 一起聊,且只顯示對話** | `persona-invite` 建聊天室 + guest 唯讀租約 + `persona-guest` sub agent;同時開啟**劇場模式**:hook 每輪強制「只輸出 `名字:內容`」、停掉所有系統提醒,CLI 有 `--quiet` 與 `room script`(乾淨對話稿)。**同場也分一對一與全場**:`room post --to <他>` 進一對一(旁人插話會被擋下,要帶 `--barge-in "<理由>"`)、`--to all` 開回全場,現況查 `room floor` | | **5. 腳本用 Node.js** | `scripts/*.mjs`、`hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat `;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona `)——設了才自動載入,沒設就什麼都不做 | @@ -466,13 +466,39 @@ node scripts/persona.mjs sleep --session --release # 收工 | --- | --- | | `SessionStart` | 清死鎖、接續人格、**有設預設人格就自動載入它**(沒設就只列出可用人格)、把 `PERSONA_SESSION=` 與規則注入上下文;載入到人格時追加 ``=該人格 `AGENTS.md` 全文(開機一次,不進每輪的 ``) | | `UserPromptSubmit` | 注入 ``:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 | -| `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**(deny 帶原因) | +| `PreToolUse` | 人格隔離與鎖驗證的強制點(deny 帶原因)——**對正常寫法有效,不擋有意繞路的對手**,見下 | | `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) | | `SubagentStop` | 解除 guest/sleeper sub agent 的 pin,並還掉 sleeper 的短期寫入權 | | `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 | > `session_id` 只有 hook 拿得到 → 注入上下文 → skills 呼叫 CLI 時必須帶 `--session`, -> hook 會驗證是否相符。**這是「一人格一程序」與「跨人格隔離」不能被繞過的關鍵**。 +> hook 會驗證是否相符。**這是「一人格一程序」與「跨人格隔離」的主要依據**。 + +### guard 擋得住什麼、擋不住什麼 + +先把定位講清楚:**guard 是防漂移的護欄,不是對抗性沙箱。** +它要解決的問題是「模型在正常工作中不小心讀到、寫到別的人格」——這種事天天會發生, +而且發生了不會有人察覺。它**不**打算擋住一個知道 guard 存在、刻意要繞過去的對手。 + +**擋得住(模型會自然寫出來的形式)** + +- `Read`/`Write`/`Edit`/`NotebookEdit`/`LS` 的路徑欄位,含 `../`、symlink、`~`、`$PERSONA_HOME`。 +- `Glob`/`Grep` 的 `path`、**樣式欄位**(`Glob.pattern`、`Grep.glob`),以及**沒給 `path` 時的 cwd**—— + `Glob { pattern: "/*/IDENTITY.md" }` 和「cwd 站在人格倉庫底下直接 `Grep`」都會被 deny。 +- `Bash` 裡直接出現人格路徑的指令(`cat`、`grep -r`、`cp`、重導向……)。 +- CLI 層的身分:假的 `--session`、主程序冒用 guest/sleeper 身分、guest 寫入、sleeper 換人睡。 + 這幾項**由 CLI 自己驗**(查鎖、查租約、查 hook 寫下的 pin),不依賴 hook 有沒有攔到。 + +**擋不住(已知,且不打算用正則去補)** + +- **直譯器逃逸**:`node -e`、`python3 -c`、`bash -c` 裡組出來的路徑,字串是在執行期才拼出來的。 +- **逐段 `cd`**:`cd ~/.claude/personas && cd beta && cat SOUL.md`——每一段單獨看都不像人格路徑。 +- **引號與變數切割 token**:`cat "$H"/be"ta"/SOUL.md` 之類的寫法。 +- 任何直接呼叫檔案系統的程式(guard 只看 hook 送來的工具參數,不是 seccomp/namespace)。 + +這條路補不完:Bash 是圖靈完備的,用正則追指令字串永遠落後一步,而且每加一條正則就多一批 +誤攔正常指令的風險。真的需要對抗性隔離,要靠作業系統層的手段(獨立使用者、容器、 +檔案權限),不是靠 hook。 --- diff --git a/hooks/guard.mjs b/hooks/guard.mjs index 1ac29e7..05b9dfb 100644 --- a/hooks/guard.mjs +++ b/hooks/guard.mjs @@ -1,11 +1,20 @@ #!/usr/bin/env node -// PreToolUse guard:人格鎖驗證 + 跨人格資料隔離(唯一的強制執行點)。 +// PreToolUse guard:人格鎖驗證 + 跨人格資料隔離。 +// +// 定位:**防漂移的護欄,不是對抗性沙箱**。它擋的是「模型在正常工作中不小心讀到、 +// 寫到別的人格」——對模型會自然寫出來的形式一律 deny;它不擋一個知道 guard 存在、 +// 刻意要繞過去的對手(直譯器逃逸、逐段 cd、引號切割 token 都繞得過,這條路用正則 +// 補不完)。詳見 README 的〈guard 擋得住什麼、擋不住什麼〉。 // // 擋下的情形: -// * 讀寫非「本 session 當前人格」的人格目錄(含 Read/Write/Edit/Glob/Grep/Bash) +// * 讀寫非「本 session 當前人格」的人格目錄(Read/Write/Edit/LS 的路徑欄位、 +// Glob/Grep 的 path 與樣式欄位、沒給 path 時的 cwd、Bash 指令裡直接出現的路徑) // * guest(persona-guest sub agent)寫入任何人格檔案,或換讀別的人格 -// * CLI 帶假的 --session(冒用其他程序身分)/主程序冒用 --as-guest +// * CLI 帶假的 --session(冒用其他程序身分)/主程序冒用 guest 或 sleeper 身分 // * 目標人格的鎖屬於其他還活著的程序 +// +// 身分類的判定 CLI 自己也會再驗一次,不把最後一道關卡放在 hook 上—— +// hook 認得出這支 CLI 靠的是檔名,改個名字就整路不表態了。 import { readEvent, respond } from "./_hook.mjs"; import * as pl from "../scripts/persona-lib.mjs"; diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 54a03df..33d9f2f 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -2717,6 +2717,14 @@ const PATH_TOOL_FIELDS = { Grep: ["path"], LS: ["path"], }; +// 樣式欄位本身就會帶路徑:`Glob { pattern: "/*/IDENTITY.md" }` 不給 `path` 也掃得到 +// 別人的身分檔,這是模型最自然會寫出來的列舉方式。它是相對於 `path`(沒給就相對 cwd) +// 解析的,所以基準點跟 PATH_TOOL_FIELDS 不同,另外列一張表。 +// 注意 `Grep.pattern` 是正規表示式、不是路徑,故意不收。 +const PATTERN_TOOL_FIELDS = { + Glob: ["pattern"], + Grep: ["glob"], +}; export const GUEST_SAFE_SUBCOMMANDS = new Set([ "show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said", @@ -2779,12 +2787,20 @@ export function personaSlugOf(target) { export function extractPaths(toolName, toolInput, cwd) { const out = []; - for (const field of PATH_TOOL_FIELDS[toolName] || []) { - const value = toolInput?.[field]; - if (typeof value === "string" && value) { - const resolved = resolvePath(value, cwd); - if (resolved) out.push(resolved); - } + const push = (value, base) => { + if (typeof value !== "string" || !value) return; + const resolved = resolvePath(value, base); + if (resolved) out.push(resolved); + }; + for (const field of PATH_TOOL_FIELDS[toolName] || []) push(toolInput?.[field], cwd); + if (PATTERN_TOOL_FIELDS[toolName]) { + const rawPath = typeof toolInput?.path === "string" ? toolInput.path : ""; + // 樣式相對於 `path`;`path` 沒給的話,搜尋起點就是 hook event 的 cwd + const base = rawPath ? resolvePath(rawPath, cwd) || cwd : cwd; + for (const field of PATTERN_TOOL_FIELDS[toolName]) push(toolInput?.[field], base); + // Grep/Glob 不給 `path` 時就是「掃 cwd」。少了這一條,cwd 站在人格倉庫底下的 + // Grep 會解析出空陣列 → guard 不表態 → 整批人格資料直接穿過去。 + if (!rawPath) push(cwd, cwd); } if (toolName === "Bash") { const command = toolInput?.command || ""; diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 50f6ca8..19e71fa 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -141,6 +141,50 @@ check("一般 sub agent 沿用 host 範圍(sub agent 不限)", guard({ session_id: S_HOST, agent_id: "ag-1", agent_type: "Explore", tool_name: "Read", tool_input: { file_path: `${H}/alpha/memory/INDEX.md` } }) === "pass"); +// --- S2 迴歸:模型最自然會寫出來的兩種列舉方式 ------------------------------ // +// (1) 樣式欄位本身就帶路徑,卻沒有 `path` +check("Glob 只給 pattern(不給 path)掃全倉庫 → 攔下", + guard({ session_id: S_HOST, tool_name: "Glob", tool_input: { pattern: `${H}/*/IDENTITY.md` } }) === "deny"); +check("Glob 只給 pattern 指名別的人格 → 攔下", + guard({ session_id: S_HOST, tool_name: "Glob", tool_input: { pattern: `${H}/beta/**/*.md` } }) === "deny"); +check("Grep 的 glob 欄位指向別的人格 → 攔下", + guard({ session_id: S_HOST, tool_name: "Grep", + tool_input: { pattern: "秘密", glob: `${H}/beta/**` } }) === "deny"); +check("Glob pattern 指向自己的人格 → 放行", + guard({ session_id: S_HOST, tool_name: "Glob", tool_input: { pattern: `${H}/alpha/**/*.md` } }) === "pass"); +check("樣式相對於 path 解析(path=自己 + pattern=**/*.md → 放行)", + guard({ session_id: S_HOST, tool_name: "Glob", + tool_input: { path: `${H}/alpha`, pattern: "**/*.md" } }) === "pass"); +check("Grep 的 pattern 是正規表示式、不當路徑看(不誤攔)", + guard({ session_id: S_HOST, tool_name: "Grep", + tool_input: { pattern: "a/b/c", path: `${H}/alpha` } }) === "pass"); +// (2) 不給 `path` 時,掃描起點就是 cwd +check("cwd 站在別的人格底下、Grep 不給 path → 攔下", + guard({ session_id: S_HOST, cwd: `${H}/beta`, tool_name: "Grep", + tool_input: { pattern: "." } }) === "deny"); +check("cwd 站在倉庫根目錄、Glob 不給 path → 攔下", + guard({ session_id: S_HOST, cwd: H, tool_name: "Glob", tool_input: { pattern: "**/SOUL.md" } }) === "deny"); +check("cwd 站在自己的人格底下、Grep 不給 path → 放行", + guard({ session_id: S_HOST, cwd: `${H}/alpha`, tool_name: "Grep", tool_input: { pattern: "." } }) === "pass"); +check("cwd 在人格倉庫外的普通專案 → 不表態(不誤攔)", + guard({ session_id: S_HOST, cwd: path.join(HERE, ".."), tool_name: "Grep", + tool_input: { pattern: "TODO" } }) === "pass"); +check("給了 path 就不再拿 cwd 當目標", + guard({ session_id: S_HOST, cwd: `${H}/beta`, tool_name: "Grep", + tool_input: { pattern: ".", path: `${H}/alpha` } }) === "pass"); +// 直譯器逃逸不用正則補,改成在文件裡誠實說明定位——這裡確保那段話還在。 +check("README 誠實標示 guard 的定位(護欄,不是對抗性沙箱)", (() => { + const md = fs.readFileSync(path.join(HERE, "..", "README.md"), "utf8"); + return md.includes("guard 擋得住什麼、擋不住什麼") && + md.includes("防漂移的護欄,不是對抗性沙箱") && + md.includes("直譯器逃逸") && md.includes("逐段 `cd`") && + !md.includes("唯一強制點"); +})()); +check("guard hook 的檔頭不再自稱唯一的強制執行點", (() => { + const src = fs.readFileSync(path.join(HOOKS, "guard.mjs"), "utf8"); + return !src.includes("唯一的強制執行點") && src.includes("不是對抗性沙箱"); +})()); + console.log("④ 情緒(六正向 + 六負向)"); check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6); cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]);