Guard for dispatching subagents: denies isolation "worktree", checks a declared WORKTREE is a separate, clean, never-dispatched git worktree, and in opted-in…

Guards subagent dispatch so each code-writing subagent works in its own clean git worktree.
拦在 Agent 工具调用前:
isolation: "worktree"(多个 agent 会共用同一个目录)。WORKTREE: <绝对路径> 时,检查它是绝对路径、存在、是 git worktree(不是主仓库)、没有未提交改动、之前没派给过别的 agent(一个目录只派一次,记录在插件 store 里,最多 2000 条)。WORKTREE: none:声明纯调研、不写代码,直接放行。Explore、Plan、claude-code-guide、statusline-setup)不检查。WORKTREE: 行就拒绝。默认没有仓库开启,所以开箱只做前三条。两种方式开启「必须带 WORKTREE 行」,任一满足即生效:
requireWorktreeRepos:仓库目录名(主 checkout 的目录名),逗号分隔,默认空。 { "pluginConfigs": { "agent-guard": { "options": { "requireWorktreeRepos": "my-app,my-api" } } } }
.claude/agent-guard.json(从主 checkout 读): {
"requireWorktree": true,
"worktreeCommand": "scripts/dev/agent-wt.sh <name>"
}
worktreeCommand 可选,是拒绝提示里教模型建 worktree 的命令;默认 git worktree add ../<repo>-<name> -b agent/<name>。
claude plugin test .
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install agent-guard@claude-lead-harnesshooks/register.ts 82 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { howto, parseConfig, READ_ONLY, repoList, worktreeLine } from './rules'
4import type { RepoConfig } from './rules'
5
6// Worktrees already handed to an agent, across sessions: one directory, one dispatch.
7const LEDGER_KEY = 'dispatched'
8
9async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
10 try {
11 const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 10_000 })
12 return r.exitCode === 0 ? r.stdout.trim() : null
13 } catch {
14 return null
15 }
16}
17
18// The main checkout of the session's repo (a worktree reports its main repo): its path and directory name.
19async function mainRepo($: EngineInterface): Promise<{ root: string; name: string } | null> {
20 const common = await git($, await $.session.cwd(), ['rev-parse', '--path-format=absolute', '--git-common-dir'])
21 if (!common) return null
22 const root = common.replace(/\/\.git\/?$/, '')
23 const name = root.split('/').pop()
24 return name ? { root, name } : null
25}
26
27// The repo's own `.claude/agent-guard.json`, read from its main checkout.
28async function repoConfig($: EngineInterface, root: string): Promise<RepoConfig> {
29 const text = await $.fs.read(`${root}/.claude/agent-guard.json`).then(t => t as string, () => null)
30 return parseConfig(text)
31}
32
33// Why the declared worktree is not usable, or null when it is.
34async function checkWorktree($: EngineInterface, path: string): Promise<string | null> {
35 if (!path.startsWith('/')) return `WORKTREE 要写绝对路径,现在是 ${path}`
36 const st = await $.fs.stat(path, { resolve: true }).catch(() => null)
37 if (!st) return `${path} 不存在`
38 const gitDir = await git($, path, ['rev-parse', '--path-format=absolute', '--git-dir'])
39 const common = await git($, path, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
40 if (!gitDir || !common) return `${path} 不是 git 目录`
41 if (gitDir === common) return `${path} 是主仓库,不是 worktree`
42 const dirty = await git($, path, ['status', '--porcelain'])
43 if (dirty === null) return `读不到 ${path} 的 git status`
44 if (dirty !== '') return `${path} 有未提交改动,不是干净的新目录`
45 const ledger = ((await $.store.get(LEDGER_KEY).catch(() => undefined)) as string[] | undefined) ?? []
46 if (ledger.includes(st.realPath ?? path)) return `${path} 之前已经派给过一个 agent,一个目录只派一次`
47 return null
48}
49
50export const register: Register = (on, options) => {
51 const required = repoList(String(options.requireWorktreeRepos ?? ''))
52
53 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
54 const repo = await mainRepo($)
55 const config = repo ? await repoConfig($, repo.root) : parseConfig(null)
56 const HOWTO = howto(config.worktreeCommand)
57
58 if (e.isolation === 'worktree') {
59 return { deny: `agent-guard:isolation: "worktree" 会让多个 agent 共用同一个目录,不用它。${HOWTO}` }
60 }
61 const type = e.subagent_type ?? 'general-purpose'
62 if (READ_ONLY.has(type)) return next(e)
63
64 const declared = worktreeLine(e.prompt)
65 if (declared === 'none') return next(e)
66 if (declared === null) {
67 if (repo && (config.requireWorktree || required.includes(repo.name))) {
68 return { deny: `agent-guard:${repo.name} 里派 ${type} 子 agent 要在 prompt 里写一行「WORKTREE: <绝对路径>」。${HOWTO}` }
69 }
70 return next(e)
71 }
72
73 const why = await checkWorktree($, declared)
74 if (why) return { deny: `agent-guard:${why}。${HOWTO}` }
75 const st = await $.fs.stat(declared, { resolve: true })
76 const ledger = ((await $.store.get(LEDGER_KEY).catch(() => undefined)) as string[] | undefined) ?? []
77 await $.store.set(LEDGER_KEY, [...ledger, st.realPath ?? declared].slice(-2000))
78 return next(e)
79 // A fault in the guard itself must not stop every dispatch.
80 }).catch(($, e, next) => next(e))
81}
82hooks/rules.ts 43 lines1// Pure rules, kept apart so the tests can call them directly.
2
3// Agent types that never write.
4export const READ_ONLY = new Set(['Explore', 'Plan', 'claude-code-guide', 'statusline-setup'])
5
6// `WORKTREE: /abs/path` or `WORKTREE: none` on a line of its own.
7export function worktreeLine(prompt: string): string | null {
8 const m = /^WORKTREE:\s*(\S+)\s*$/m.exec(prompt)
9 return m ? (m[1] ?? null) : null
10}
11
12export const repoList = (csv: string): string[] =>
13 csv.split(',').map(s => s.trim()).filter(Boolean)
14
15// A repo's own `.claude/agent-guard.json`.
16export type RepoConfig = {
17 // Non-read-only subagents dispatched in this repo must carry a WORKTREE line.
18 requireWorktree: boolean
19 // How to make a worktree in this repo, shown in the deny message; `<name>` is a placeholder.
20 worktreeCommand: string | null
21}
22
23export function parseConfig(text: string | null): RepoConfig {
24 const none: RepoConfig = { requireWorktree: false, worktreeCommand: null }
25 if (text === null) return none
26 try {
27 const j = JSON.parse(text) as Record<string, unknown>
28 if (typeof j !== 'object' || j === null) return none
29 return {
30 requireWorktree: j.requireWorktree === true,
31 worktreeCommand: typeof j.worktreeCommand === 'string' && j.worktreeCommand.trim() ? j.worktreeCommand.trim() : null,
32 }
33 } catch {
34 return none
35 }
36}
37
38export const DEFAULT_WORKTREE_COMMAND = 'git worktree add ../<repo>-<name> -b agent/<name>'
39
40export const howto = (worktreeCommand: string | null = null): string =>
41 `做法:先建独占 worktree(${worktreeCommand ?? DEFAULT_WORKTREE_COMMAND}),` +
42 '把路径写进 prompt 单独一行「WORKTREE: <绝对路径>」,让子 agent 只在那里改、跑闸、提交;纯调研不写代码的写「WORKTREE: none」。'
43