docs(persona-chat): clarify auto-hook boundary #13

Merged
admin merged 19 commits from pr/persona-master-sync-20260731 into master 2026-07-31 17:37:42 +00:00
4 changed files with 107 additions and 12 deletions
Showing only changes of commit 7549c0b69f - Show all commits
+29 -3
View File
@@ -58,7 +58,7 @@ persona/
| **1a. 用與 OpenClaw 相同的描述建立人格** | `persona-create` 逐項索取 `Name` / `Creature` / `Vibe` / `Emoji` / `Avatar`(連括號提示文字都照 OpenClaw 原文),`SOUL.md` 沿用 `Core Truths` / `Boundaries` / `Vibe` / `Continuity` 段落結構 | | **1a. 用與 OpenClaw 相同的描述建立人格** | `persona-create` 逐項索取 `Name` / `Creature` / `Vibe` / `Emoji` / `Avatar`(連括號提示文字都照 OpenClaw 原文),`SOUL.md` 沿用 `Core Truths` / `Boundaries` / `Vibe` / `Continuity` 段落結構 |
| **1b. 動漫作品+角色名快速建人格** | `persona-anime` 先上網蒐集該角色的公開設定(至少 3 個獨立來源),映射成上述五欄位與 SOUL,再把設定固化成 `canon` 基礎記憶(每則帶來源 URL)+原作人際關係圖+情緒基線 | | **1b. 動漫作品+角色名快速建人格** | `persona-anime` 先上網蒐集該角色的公開設定(至少 3 個獨立來源),映射成上述五欄位與 SOUL,再把設定固化成 `canon` 基礎記憶(每則帶來源 URL)+原作人際關係圖+情緒基線 |
| **2. 同一個人格只能被一個程序載入(Sub Agent 不限)** | `state/lock.json`**session_id** 為主鍵、15 分鐘心跳租約;同一 session 的 sub agent 沿用同一把鎖,跨 session 搶佔會被拒;租約過期才可接手(並強制回報) | | **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` 繞路),非當前人格一律 denyCLI 也驗 `--session` 防止冒用身分 | | **3. 禁止跨人格讀取資料** | `PreToolUse` hook 對 Read/Write/Edit/Glob/Grep/Bash 做路徑判定(含 `../`、symlink、`$PERSONA_HOME` 繞路、Glob/Grep 的樣式欄位與 cwd),指向非當前人格 denyCLI 也驗 `--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` | | **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 依賴 | | **5. 腳本用 Node.js** | `scripts/*.mjs``hooks/*.mjs`,只用 Node 內建模組(fs/path/os/crypto),無 npm 依賴 |
| **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat <slug>`;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona <slug>`)——設了才自動載入,沒設就什麼都不做 | | **6. 由使用者呼叫才載入並鎖定** | 人格不會自動附身:`SessionStart` hook 只列出可用人格,等使用者下 `/jsc-persona:persona-chat <slug>`;載入即取得獨占鎖並綁定該 session。唯一例外是使用者自己設的**預設人格**(`default --persona <slug>`)——設了才自動載入,沒設就什麼都不做 |
@@ -466,13 +466,39 @@ node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工
| --- | --- | | --- | --- |
| `SessionStart` | 清死鎖、接續人格、**有設預設人格就自動載入它**(沒設就只列出可用人格)、把 `PERSONA_SESSION=<session_id>` 與規則注入上下文;載入到人格時追加 `<persona-ops>`=該人格 `AGENTS.md` 全文(開機一次,不進每輪的 `<persona-context>` | | `SessionStart` | 清死鎖、接續人格、**有設預設人格就自動載入它**(沒設就只列出可用人格)、把 `PERSONA_SESSION=<session_id>` 與規則注入上下文;載入到人格時追加 `<persona-ops>`=該人格 `AGENTS.md` 全文(開機一次,不進每輪的 `<persona-context>` |
| `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 | | `UserPromptSubmit` | 注入 `<persona-context>`:身分、情緒、短期記憶、關鍵詞命中的長期記憶、相關人際關係;劇場模式時追加「只輸出人格對話」的強制規則;並記原始逐字 |
| `PreToolUse` | **人格隔離與鎖驗證的唯一強制點**deny 帶原因) | | `PreToolUse` | 人格隔離與鎖驗證的強制點(deny 帶原因)——**對正常寫法有效,不擋有意繞路的對手**,見下 |
| `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) | | `Stop` | 情緒隨時間衰減、續租、記錄回覆、達固化條件時提醒(劇場模式時完全靜音) |
| `SubagentStop` | 解除 guestsleeper sub agent 的 pin,並還掉 sleeper 的短期寫入權 | | `SubagentStop` | 解除 guestsleeper sub agent 的 pin,並還掉 sleeper 的短期寫入權 |
| `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 | | `SessionEnd` | 釋放鎖與 guest 租約,人格才能被下一個程序載入 |
> `session_id` 只有 hook 拿得到 → 注入上下文 → skills 呼叫 CLI 時必須帶 `--session` > `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: "<home>/*/IDENTITY.md" }` 和「cwd 站在人格倉庫底下直接 `Grep`」都會被 deny。
- `Bash` 裡直接出現人格路徑的指令(`cat`、`grep -r`、`cp`、重導向……)。
- CLI 層的身分:假的 `--session`、主程序冒用 guestsleeper 身分、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 送來的工具參數,不是 seccompnamespace)。
這條路補不完:Bash 是圖靈完備的,用正則追指令字串永遠落後一步,而且每加一條正則就多一批
誤攔正常指令的風險。真的需要對抗性隔離,要靠作業系統層的手段(獨立使用者、容器、
檔案權限),不是靠 hook。
--- ---
+12 -3
View File
@@ -1,11 +1,20 @@
#!/usr/bin/env node #!/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 指令裡直接出現的路徑)
// * guestpersona-guest sub agent)寫入任何人格檔案,或換讀別的人格 // * guestpersona-guest sub agent)寫入任何人格檔案,或換讀別的人格
// * CLI 帶假的 --session(冒用其他程序身分)/主程序冒用 --as-guest // * CLI 帶假的 --session(冒用其他程序身分)/主程序冒用 guest 或 sleeper 身分
// * 目標人格的鎖屬於其他還活著的程序 // * 目標人格的鎖屬於其他還活著的程序
//
// 身分類的判定 CLI 自己也會再驗一次,不把最後一道關卡放在 hook 上——
// hook 認得出這支 CLI 靠的是檔名,改個名字就整路不表態了。
import { readEvent, respond } from "./_hook.mjs"; import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs"; import * as pl from "../scripts/persona-lib.mjs";
+21 -5
View File
@@ -2717,6 +2717,14 @@ const PATH_TOOL_FIELDS = {
Grep: ["path"], Grep: ["path"],
LS: ["path"], LS: ["path"],
}; };
// 樣式欄位本身就會帶路徑:`Glob { pattern: "<home>/*/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([ export const GUEST_SAFE_SUBCOMMANDS = new Set([
"show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said", "show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said",
@@ -2779,12 +2787,20 @@ export function personaSlugOf(target) {
export function extractPaths(toolName, toolInput, cwd) { export function extractPaths(toolName, toolInput, cwd) {
const out = []; const out = [];
for (const field of PATH_TOOL_FIELDS[toolName] || []) { const push = (value, base) => {
const value = toolInput?.[field]; if (typeof value !== "string" || !value) return;
if (typeof value === "string" && value) { const resolved = resolvePath(value, base);
const resolved = resolvePath(value, cwd);
if (resolved) out.push(resolved); 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);
// GrepGlob 不給 `path` 時就是「掃 cwd」。少了這一條,cwd 站在人格倉庫底下的
// Grep 會解析出空陣列 → guard 不表態 → 整批人格資料直接穿過去。
if (!rawPath) push(cwd, cwd);
} }
if (toolName === "Bash") { if (toolName === "Bash") {
const command = toolInput?.command || ""; const command = toolInput?.command || "";
+44
View File
@@ -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", 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"); 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("④ 情緒(六正向 + 六負向)"); console.log("④ 情緒(六正向 + 六負向)");
check("十二種情緒", pl.EMOTION_KEYS.length === 12 && pl.POSITIVE.length === 6 && pl.NEGATIVE.length === 6); 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"]); cli(["emotion", "--persona", "alpha", "--session", S_HOST, "--apply", "joy=+60,anger=+40", "--trigger", "selftest"]);