fix(guard): 補上 Glob/Grep 的兩個自然破口,並誠實標示 guard 的定位(S2)

`extractPaths()` 只解析「含 / 且展開後包含 home 或 personas」的 token,
於是兩種**模型最自然會寫出來的列舉方式**整路穿過去:

1. `PATH_TOOL_FIELDS.Glob` 只列 `path`,所以樣式欄位不被檢查——
   `Glob { pattern: "<home>/*/IDENTITY.md" }`(不給 `path`)可以掃出全部人格的身分檔。
   新增 `PATTERN_TOOL_FIELDS`(`Glob.pattern`、`Grep.glob`),且**相對於 `path` 解析**
   (沒給 `path` 才相對 cwd),因為樣式的基準點跟路徑欄位不同。
   `Grep.pattern` 是正規表示式、不是路徑,故意不收,免得誤攔 `a/b/c` 這種樣式。
2. `Grep`/`Glob` 沒給 `path` 時 `extractPaths` 回空陣列 → guard 不表態,
   於是 cwd 站在 `~/.claude/personas` 或別人的人格底下直接 `Grep` 就整批穿過去。
   改成把 hook event 的 `cwd` 當預設目標。

其餘的直譯器逃逸(`node -e`、`python3 -c`、逐段 `cd`、引號切割 token)**不用正則補**:
Bash 圖靈完備,追指令字串永遠落後一步,每加一條正則就多一批誤攔正常指令的風險。
改為把文件的措辭修正成誠實的定位——

- README 新增〈guard 擋得住什麼、擋不住什麼〉:明說 guard 是**防漂移的護欄,
  對正常寫法一律 deny,不是對抗性沙箱**,並逐條列出擋得住與擋不住的形式,
  以及「真要對抗性隔離請走 OS 層」。
- 拿掉會誤導的字眼:`PreToolUse` 那列的「唯一強制點」、guard.mjs 檔頭的
  「唯一的強制執行點」、「不能被繞過的關鍵」。

測試:新增 13 項——樣式欄位指向全倉庫/別的人格/自己、樣式相對 path 解析、
`Grep.pattern` 不誤判成路徑、不給 path 時 cwd 在別人格/倉庫根/自己人格/
普通專案的四種情形、給了 path 就不看 cwd,以及兩項文件措辭的迴歸檢查。
355 → 368 項全過。
This commit is contained in:
2026-07-31 09:23:28 +00:00
parent b1e77b01a1
commit 7549c0b69f
4 changed files with 107 additions and 12 deletions
+22 -6
View File
@@ -2717,6 +2717,14 @@ const PATH_TOOL_FIELDS = {
Grep: ["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([
"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);
// GrepGlob 不給 `path` 時就是「掃 cwd」。少了這一條,cwd 站在人格倉庫底下的
// Grep 會解析出空陣列 → guard 不表態 → 整批人格資料直接穿過去。
if (!rawPath) push(cwd, cwd);
}
if (toolName === "Bash") {
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",
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"]);