SLOPSHOPPER

agent-guard

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

newguardprocess
A shopper browsing a rack in a slop shop
README

agent-guard

Guards subagent dispatch so each code-writing subagent works in its own clean git worktree.

拦在 Agent 工具调用前:

  • 拒绝 isolation: "worktree"(多个 agent 会共用同一个目录)。
  • prompt 里单独一行 WORKTREE: <绝对路径> 时,检查它是绝对路径、存在、是 git worktree(不是主仓库)、没有未提交改动、之前没派给过别的 agent(一个目录只派一次,记录在插件 store 里,最多 2000 条)。
  • WORKTREE: none:声明纯调研、不写代码,直接放行。
  • 只读类型(Explore、Plan、claude-code-guide、statusline-setup)不检查。
  • 在已开启的仓库里,非只读子 agent 没写 WORKTREE: 行就拒绝。默认没有仓库开启,所以开箱只做前三条。
  • 守卫自身出错时放行,不会卡住派发。

配置

两种方式开启「必须带 WORKTREE 行」,任一满足即生效:

  1. 插件设置 requireWorktreeRepos:仓库目录名(主 checkout 的目录名),逗号分隔,默认空。
   { "pluginConfigs": { "agent-guard": { "options": { "requireWorktreeRepos": "my-app,my-api" } } } }
  1. 在仓库里放 .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-harness
Source 2 files
hooks/register.ts 82 lines
1import 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}
82
hooks/rules.ts 43 lines
1// 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