SLOPSHOPPER

guard-pack

Mod pack (function hooks, no daemon needed): keeps file tools inside this project's roots, redacts secret-shaped values in tool results before Claude sees them…

newguardstatusagents
★ 7v0.1.0Apache-2.0updated 2026-10-14atretyak1985/swarmery/plugins/guard-pack
A shopper browsing a rack in a slop shop
README

guard-pack

Guards for running many projects on one machine, written as a mod: one hooks module of function hooks (hooks/hooks.json → hooks/register.ts), not shell scripts. It needs no daemon. Each session reads its policy from the project's own .claude/project.json through $.fs when it starts. When a hook cannot judge, it falls through to the next hook, so a broken guard never blocks a session.

GuardHookWhat it does
Isolationtool.callDenies Read, Edit, Write, MultiEdit, NotebookEdit, Glob and Grep on a path outside the project's roots. In Bash it denies rm/mv/cp of an absolute path outside the roots, an output redirect (>, >>, &>) to one, and cd into one. The deny reason is one line naming the path, the first roots, and the extraRoots option.
Redactionsession.appendBefore Claude sees a tool result, replaces every secret-shaped value inside its tool_result block with [REDACTED:<rule>]. The raw output may still be kept in the local transcript (see Limits). Prompt rows, assistant rows and the row's other blocks are never touched. The status line counts redactions (guard: 3 secrets redacted).
Production guardtool.checkSends a call that matches a production-deploy pattern to the native ask dialog, even where the permission rules would have allowed it. A deny from beneath stays a deny. In auto mode the ask is settled without a dialog (see Limits).
Agent offeragent.offerHides <name>-pack agents of packs this project has not enabled from the model. Off by default.
Heavy-tool deferraltool.describePuts an MCP tool whose description is longer than 2048 characters behind ToolSearch. Off by default.

Roots

These are read on session.start, which fires again on every reload:

  • the session root and cwd ($.session.root(), $.session.cwd());
  • with a .claude/project.json: the directory that holds it, plus every repos[] entry, resolved relative to that directory ("../shared-lib" works);
  • <AGENT_WORKSPACE_ROOT>/<AGENT_PROJECT> when both are set (the project's name stands in for an unset AGENT_PROJECT);
  • ~/.claude, /tmp, /private/tmp, $TMPDIR (and its /private spelling);
  • extraRoots.

With no project.json the guard still runs, with the session root, ~/.claude and the temp dirs as its roots.

Patterns

  • hooks/patterns/prod.json holds the default production-deploy globs. It has the same strings, in the same order, as the daemon's DefaultProdDeployPatterns. The project's own approvals.prodDeployPatterns from project.json are added to them. The syntax is Tool or Tool(argGlob): the tool name must match exactly, the glob is matched case-insensitively against the tool's argument string (command for Bash), and * matches any run of characters.
  • hooks/patterns/secrets.json holds the redaction rules, ported from the daemon's response-layer redaction. Each rule has a stable name (the <rule> in the mark), a JavaScript pattern, optional flags, and an optional replace template ($1 keeps a captured prefix such as Bearer , and {mark} is the mark itself).

A hooks module can import only code, so the runtime reads both files through $.fs. The tests run with no file system, so hooks/testkit.ts carries a verbatim copy of each file between // BEGIN mirror: and // END mirror: markers. The daemon's parity tests (go test ./internal/approvals ./internal/api -run Parity in tools/swarmery) compare the JSON files with the Go lists and fail when a testkit copy differs from its file, so a pattern edit means editing the Go list, the JSON file and the testkit copy together.

Options (userConfig)

KeyTypeDefaultEffect
isolationbooleantrueThe isolation guard. Off registers no tool.call hook.
redactionbooleantrueThe redaction guard. Off registers no session.append hook.
prodGuardbooleantrueThe production guard. Off registers no tool.check hook.
agentOfferbooleanfalseHide agents of packs this project has not enabled. A pack counts as enabled when it appears in project.json enabledPacks (or an enabledPlugins there), or in the project or local settings' enabledPlugins. User-level settings do not count.
deferHeavyToolsbooleanfalseDefer MCP tools with a description over 2048 characters.
extraRootsstring""Comma-separated extra roots: absolute paths, ~/…, or paths relative to the project directory.

Options are stored in settings under pluginConfigs["guard-pack"].options, and each one is a row in /config.

Enable

Install it from the swarmery marketplace:

/plugin install guard-pack@swarmery

Or enable it for one project, in that project's .claude/settings.json:

{ "enabledPlugins": { "guard-pack@swarmery": true } }

For one session from a checkout: claude --plugin-dir plugins/guard-pack.

Checks:

claude plugin validate --strict --json plugins/guard-pack
claude plugin test plugins/guard-pack

Relationship to the other guards

Everything here is additive, and nothing depends on core or on the daemon:

  • core's shell hooks still run. protect-sensitive-files.sh decides which files must never be edited inside a project; guard-pack decides which directories belong to the project at all. prod-deploy-guard.sh asks on the same default patterns, and asking twice changes nothing.
  • the daemon's prod guard keeps marking those calls as local-only approvals. guard-pack makes sure the native dialog appears even in a session the daemon does not watch.

Limits

  • Paths are lexical. ~, ., .. and repeated / are folded, and no symlink is resolved. A symlink inside a root that points outside it is allowed. A path outside spelled through a symlink into a root (other than /tmp and /var → /private/…, which are listed both ways) is denied, so add it to extraRoots.
  • Comparison ignores case. /Work/x and /work/x are the same path on a case-insensitive volume. On a case-sensitive one this lets a differently cased sibling directory through.
  • Bash is a heuristic, not a shell parser. Only literal absolute paths (/…, ~, ~/…) count. A word holding $ or a backtick, a relative path, eval, and paths built at runtime all pass. Of the commands, only rm, mv, cp, output redirects and cd are judged. Reading from outside with cat or ls is not blocked. A false deny costs more than a miss.
  • Hooks that run before session.start has been read let every call through.
  • auto mode settles the production ask itself. Observed on Claude Code 2.1.296: with decision: 'ask' from tool.check, default mode draws the native dialog ("Hook tool.check requires confirmation for this command: … [plugin:guard-pack]"), and -p in default mode refuses the call ("Bash needs approval (asked by plugin guard-pack: …)"). In auto mode, interactive or -p, no dialog appeared and the matching command ran. Where production calls must stop in auto mode, add a permission deny rule for them; whether core's prod-deploy-guard.sh ask survives auto mode was not checked.
  • Redaction hides secrets from Claude, not from disk. It rewrites the tool_result blocks Claude sees. The raw output may still be kept in the local transcript: observed on Claude Code 2.1.296, the row's structured toolUseResult field is not rewritten, so a Bash result's stdout is still in the stored transcript JSONL and in --output-format stream-json as tool_use_result. It does not cover what a tool already printed to the terminal while it ran, and the prompt and the tool call's own input are never rewritten either.
Source 8 files
hooks/register.ts 155 lines
1// guard-pack: project isolation, secret redaction, the production-deploy
2// guard and what the model is offered. Policy is read through `$` alone (no
3// daemon) on session.start, which fires again on every module reload; the
4// other hooks judge from that snapshot with pure helpers, return fast, and
5// fall through to `next` when they cannot judge: a guard that fails never
6// blocks the session. A userConfig option turned off registers none of its
7// hooks. Every `$` call is spelled here, at its call site, as the engine
8// requires.
9
10import type { Register } from 'claude-code'
11import { judgeToolCall } from './isolation'
12import { type PackPolicy, isHeavyMcpTool, isOffered, packPolicy } from './offer'
13import { type ProdPattern, type SecretRule, compileProd, compileSecrets, parseJson } from './patterns'
14import { matchProd, projectProdPatterns, prodReason } from './prodguard'
15import { hasToolResult, redactBlocks, statusLine } from './redaction'
16import { type GuardContext, type ProjectConfig, buildContext, parseOptions, parseProject } from './roots'
17
18/** A `$` call's answer, or undefined when it rejects. */
19const settle = async <T>(call: Promise<T>): Promise<T | undefined> => {
20  try {
21    return await call
22  } catch {
23    return undefined
24  }
25}
26
27/** Swallows a display call's failure, sync or async: display is a courtesy. */
28const ignore = (result: unknown): void => {
29  if (result instanceof Promise) result.catch(() => undefined)
30}
31
32export const register: Register = (on, rawOptions) => {
33  const options = parseOptions(rawOptions)
34
35  let ctx: GuardContext | undefined
36  let secrets: readonly SecretRule[] = []
37  let prod: readonly ProdPattern[] = []
38  let policy: PackPolicy | undefined
39  let redactedTotal = 0
40
41  on('session.start', async ($, e, next) => {
42    const result = await next(e)
43    try {
44      const cwd = (await settle($.session.cwd())) ?? e.cwd
45      const root = (await settle($.session.root())) ?? cwd
46      const home = await settle($.env.get('HOME'))
47      const tmpdir = await settle($.env.get('TMPDIR'))
48      const workspaceRoot = await settle($.env.get('AGENT_WORKSPACE_ROOT'))
49      const agentProject = await settle($.env.get('AGENT_PROJECT'))
50
51      let project: ProjectConfig | undefined
52      for (const dir of [...new Set([cwd, root])]) {
53        project = parseProject(dir, await settle($.fs.read(`${dir}/.claude/project.json`)))
54        if (project !== undefined) break
55      }
56      ctx = buildContext({ cwd, root, home, tmpdir, workspaceRoot, agentProject, project }, options)
57
58      if (options.redaction) {
59        secrets = compileSecrets(parseJson(await settle($.fs.read(`${$.plugin.root}/hooks/patterns/secrets.json`))))
60      }
61      if (options.prodGuard) {
62        const shipped = compileProd(parseJson(await settle($.fs.read(`${$.plugin.root}/hooks/patterns/prod.json`))))
63        prod = [...shipped, ...projectProdPatterns(project)]
64      }
65      if (options.agentOffer) {
66        const projectSettings = await settle($.settings.read({ source: 'project' }))
67        const localSettings = await settle($.settings.read({ source: 'local' }))
68        policy = packPolicy(project, projectSettings?.enabledPlugins, localSettings?.enabledPlugins)
69      }
70    } catch {
71      // keep whatever was read: an unread guard lets everything through
72    }
73    return result
74  })
75
76  if (options.isolation) {
77    on('tool.call', async ($, e, next) => {
78      let reason: string | undefined
79      try {
80        reason =
81          ctx === undefined
82            ? undefined
83            : judgeToolCall(ctx, String(e.tool), e as unknown as Readonly<Record<string, unknown>>)
84      } catch {
85        reason = undefined
86      }
87      return reason === undefined ? next(e) : { deny: reason }
88    }).catch(($, e, next) => next(e))
89  }
90
91  if (options.redaction) {
92    on('session.append', async ($, e, next) => {
93      const content: unknown = e.message.content
94      if (secrets.length === 0 || !hasToolResult(content)) return next(e)
95      let redacted: { blocks: unknown[]; count: number } | undefined
96      try {
97        redacted = redactBlocks(content, secrets)
98      } catch {
99        redacted = undefined
100      }
101      if (redacted === undefined || redacted.count === 0) return next(e)
102      redactedTotal += redacted.count
103      try {
104        ignore($.ui.status(statusLine(redactedTotal)))
105      } catch {
106        // the redaction stands without its status line
107      }
108      return next({ ...e, message: { ...e.message, content: redacted.blocks as typeof e.message.content } })
109    }).catch(($, e, next) => next(e))
110  }
111
112  if (options.prodGuard) {
113    on('tool.check', async ($, e, next) => {
114      const below = await next(e)
115      if (below.decision === 'deny') return below
116      let hit: ProdPattern | undefined
117      try {
118        hit = matchProd(prod, String(e.tool), e.input)
119      } catch {
120        hit = undefined
121      }
122      if (hit === undefined) return below
123      const reason = prodReason(hit)
124      const id = e.tool_use_id
125      if (id !== undefined) {
126        try {
127          ignore($.ui.notice(id, reason))
128        } catch {
129          // no open dialog to annotate yet; the ask stands
130        }
131      }
132      return { decision: 'ask', reason }
133    }).catch(($, e, next) => next(e))
134  }
135
136  if (options.agentOffer) {
137    on('agent.offer', async ($, e, next) => {
138      let offered = true
139      try {
140        offered = policy === undefined || isOffered(policy, e.agent, e.provider.plugin)
141      } catch {
142        offered = true
143      }
144      return offered ? next(e) : { isOffered: false }
145    }).catch(($, e, next) => next(e))
146  }
147
148  if (options.deferHeavyTools) {
149    on('tool.describe', async ($, e, next) => {
150      const below = await next(e)
151      return isHeavyMcpTool(e.tool, e.provider.plugin, below.description) ? { ...below, isDeferred: true } : below
152    }).catch(($, e, next) => next(e))
153  }
154}
155
hooks/isolation.ts 298 lines
1// Isolation: file tools stay inside the session's roots; destructive Bash
2// (rm/mv/cp, a write redirect, cd) may not name an absolute path outside
3// them. Lexical only (see paths.ts). A miss is preferred to a false deny:
4// anything the heuristics cannot read (a `$VAR`, a relative path in Bash)
5// passes.
6
7import { isDevicePath, isInsideAny, normalize } from './paths'
8import { type GuardContext, describeRoots } from './roots'
9
10const HINT = "add it to guard-pack's extraRoots option if it belongs to this project"
11
12const outsideReason = (path: string, ctx: GuardContext, what: string): string =>
13  `guard-pack: ${what}${path} is outside this project's roots (${describeRoots(ctx.roots)}); ${HINT}.`
14
15const GLOB_CHARS = /[*?[{]/
16
17/** The part of a path before its first glob character, folded to a directory. */
18const staticPrefix = (path: string): string => {
19  const i = path.search(GLOB_CHARS)
20  if (i < 0) return path
21  const head = path.slice(0, i)
22  return head.slice(0, head.lastIndexOf('/') + 1) || '/'
23}
24
25const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v : undefined)
26
27/** The paths one file-tool call names, by tool. */
28export const fileToolPaths = (tool: string, input: Readonly<Record<string, unknown>>): string[] => {
29  switch (tool) {
30    case 'Read':
31    case 'Edit':
32    case 'Write':
33    case 'MultiEdit':
34      return [str(input.file_path)].filter((p): p is string => p !== undefined)
35    case 'NotebookEdit':
36      return [str(input.notebook_path)].filter((p): p is string => p !== undefined)
37    case 'Glob': {
38      const out: string[] = []
39      const path = str(input.path)
40      if (path !== undefined) out.push(path)
41      const pattern = str(input.pattern)
42      if (pattern !== undefined && (pattern.startsWith('/') || pattern.startsWith('~'))) {
43        out.push(staticPrefix(pattern))
44      }
45      return out
46    }
47    case 'Grep':
48      return [str(input.path)].filter((p): p is string => p !== undefined)
49    default:
50      return []
51  }
52}
53
54export const FILE_TOOLS: ReadonlySet<string> = new Set([
55  'Read',
56  'Edit',
57  'Write',
58  'MultiEdit',
59  'NotebookEdit',
60  'Glob',
61  'Grep',
62])
63
64type Segment = { words: string[]; writes: string[] }
65
66type Heredoc = { delimiter: string; stripTabs: boolean }
67
68const DELIMITER_END = /[\s;|&<>()]/
69
70/**
71 * Reads a heredoc delimiter word starting at `from` (`EOF`, `'EOF'`, `"EOF"`,
72 * `\EOF`, or a mix): its unquoted text and the index of its last character.
73 */
74const readDelimiter = (command: string, from: number): { delimiter: string; last: number } => {
75  let delimiter = ''
76  let i = from
77  while (i < command.length && !DELIMITER_END.test(command[i] ?? '')) {
78    const c = command[i] ?? ''
79    if (c === "'" || c === '"') {
80      const end = command.indexOf(c, i + 1)
81      const stop = end < 0 ? command.length : end
82      delimiter += command.slice(i + 1, stop)
83      i = stop + 1
84    } else if (c === '\\') {
85      delimiter += command[i + 1] ?? ''
86      i += 2
87    } else {
88      delimiter += c
89      i++
90    }
91  }
92  return { delimiter, last: i - 1 }
93}
94
95/**
96 * Skips the bodies of the heredocs opened on the line that ends at `newline`,
97 * in order. Returns the index of the last character consumed: the newline
98 * that ends the last terminator line. An unterminated heredoc swallows the
99 * rest of the command (as bash reads it to end of input): its lines are text
100 * fed to a command, not commands, so they are not judged.
101 */
102const skipHeredocBodies = (command: string, newline: number, heredocs: readonly Heredoc[]): number => {
103  let start = newline + 1
104  for (const { delimiter, stripTabs } of heredocs) {
105    for (;;) {
106      if (start >= command.length) return command.length
107      const nl = command.indexOf('\n', start)
108      const end = nl < 0 ? command.length : nl
109      const raw = command.slice(start, end)
110      const line = stripTabs ? raw.replace(/^\t+/, '') : raw
111      start = end + 1
112      if (line === delimiter) break
113    }
114  }
115  return start - 1
116}
117
118/**
119 * Splits a command into simple commands: their words (quotes removed) and
120 * the targets of their output redirects. Not a shell parser: enough to read
121 * `rm -rf /x`, `cd ~/y`, `cmd > /z`, `a && b`, `2>&1`.
122 *
123 * Heredoc bodies (`<<WORD`, `<<'WORD'`, `<<"WORD"`, `<<-WORD`, up to the
124 * terminator line) are skipped: they are data, so `cat > s.sh <<'EOF'` with
125 * an `rm -rf /x` line inside is not an rm. The line holding `<<` is still
126 * read in full, so `cat > /outside/x <<EOF` is judged by its redirect.
127 * Known miss: an arithmetic shift (`$((1<<2))`) reads as a heredoc and hides
128 * the lines after it, which lets them through rather than denying them.
129 */
130export const lexCommand = (command: string): Segment[] => {
131  const segments: Segment[] = []
132  let seg: Segment = { words: [], writes: [] }
133  let word = ''
134  let inWord = false
135  let pending: 'write' | 'read' | undefined
136  let heredocs: Heredoc[] = []
137
138  const endWord = (): void => {
139    if (!inWord) return
140    if (pending === 'write') seg.writes.push(word)
141    else if (pending === undefined) seg.words.push(word)
142    pending = undefined
143    word = ''
144    inWord = false
145  }
146  const endSegment = (): void => {
147    endWord()
148    pending = undefined
149    if (seg.words.length > 0 || seg.writes.length > 0) segments.push(seg)
150    seg = { words: [], writes: [] }
151  }
152
153  for (let i = 0; i < command.length; i++) {
154    const c = command[i] ?? ''
155    if (c === '\\') {
156      word += command[i + 1] ?? ''
157      inWord = true
158      i++
159    } else if (c === "'") {
160      const end = command.indexOf("'", i + 1)
161      const stop = end < 0 ? command.length : end
162      word += command.slice(i + 1, stop)
163      inWord = true
164      i = stop
165    } else if (c === '"') {
166      let j = i + 1
167      while (j < command.length && command[j] !== '"') {
168        if (command[j] === '\\' && j + 1 < command.length) j++
169        word += command[j] ?? ''
170        j++
171      }
172      inWord = true
173      i = j
174    } else if (c === ' ' || c === '\t') {
175      endWord()
176    } else if (c === '\n') {
177      endSegment()
178      if (heredocs.length > 0) {
179        i = skipHeredocBodies(command, i, heredocs)
180        heredocs = []
181      }
182    } else if (c === ';' || c === '|' || c === '(' || c === ')') {
183      endSegment()
184    } else if (c === '&') {
185      if (command[i + 1] === '>') {
186        endWord()
187        i += command[i + 2] === '>' ? 2 : 1
188        pending = 'write'
189      } else {
190        endSegment()
191      }
192    } else if (c === '>' || c === '<') {
193      // a word made only of digits right before is the fd (`2>`), not an argument
194      if (inWord && /^\d+$/.test(word)) {
195        word = ''
196        inWord = false
197      } else {
198        endWord()
199      }
200      if (c === '<' && command[i + 1] === '<') {
201        if (command[i + 2] === '<') {
202          // `<<<word`: a here-string, its word is read like `< file`
203          i += 2
204          pending = 'read'
205          continue
206        }
207        // `<<WORD` / `<<-WORD`: a heredoc; its body starts after this line
208        i += 2
209        const stripTabs = command[i] === '-'
210        if (stripTabs) i++
211        while (command[i] === ' ' || command[i] === '\t') i++
212        const { delimiter, last } = readDelimiter(command, i)
213        if (delimiter !== '') heredocs.push({ delimiter, stripTabs })
214        i = last
215        pending = undefined
216        continue
217      }
218      if (c === '>' && command[i + 1] === '>') i++
219      if (command[i + 1] === '&') {
220        // `>&1`, `2>&1`: a dup, no file
221        i++
222        while (/\d|-/.test(command[i + 1] ?? '')) i++
223        pending = undefined
224        continue
225      }
226      pending = c === '>' ? 'write' : 'read'
227    } else {
228      word += c
229      inWord = true
230    }
231  }
232  endSegment()
233  return segments
234}
235
236const PREFIX_WORDS = new Set(['sudo', 'command', 'builtin', 'exec', 'nohup', 'time'])
237const DESTRUCTIVE = new Set(['rm', 'mv', 'cp'])
238
239const isAbsoluteSpelling = (w: string): boolean =>
240  !w.includes('$') && !w.includes('`') && (w.startsWith('/') || w === '~' || w.startsWith('~/'))
241
242/** The first absolute path a destructive part of `command` names outside the roots. */
243export const bashViolation = (
244  command: string,
245  ctx: GuardContext,
246): { path: string; what: string } | undefined => {
247  const outside = (raw: string): string | undefined => {
248    if (!isAbsoluteSpelling(raw)) return undefined
249    const path = normalize(staticPrefix(raw), ctx.cwd, ctx.home)
250    if (isDevicePath(path) || isInsideAny(path, ctx.roots)) return undefined
251    return path
252  }
253
254  for (const seg of lexCommand(command)) {
255    for (const target of seg.writes) {
256      const path = outside(target)
257      if (path !== undefined) return { path, what: 'a redirect to ' }
258    }
259    let words = seg.words
260    while (words[0] !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1)
261    while (words[0] !== undefined && PREFIX_WORDS.has(words[0])) words = words.slice(1)
262    const cmd = words[0]
263    if (cmd === undefined) continue
264    const name = cmd.slice(cmd.lastIndexOf('/') + 1)
265    const args = words.slice(1).filter(w => !w.startsWith('-'))
266    if (DESTRUCTIVE.has(name)) {
267      for (const arg of args) {
268        const path = outside(arg)
269        if (path !== undefined) return { path, what: `${name} of ` }
270      }
271    } else if (name === 'cd' && args[0] !== undefined) {
272      const path = outside(args[0])
273      if (path !== undefined) return { path, what: 'cd into ' }
274    }
275  }
276  return undefined
277}
278
279/** The deny reason for one tool call, or undefined to let it through. */
280export const judgeToolCall = (
281  ctx: GuardContext,
282  tool: string,
283  input: Readonly<Record<string, unknown>>,
284): string | undefined => {
285  if (tool === 'Bash') {
286    const command = str(input.command)
287    if (command === undefined) return undefined
288    const hit = bashViolation(command, ctx)
289    return hit === undefined ? undefined : outsideReason(hit.path, ctx, hit.what)
290  }
291  if (!FILE_TOOLS.has(tool)) return undefined
292  for (const p of fileToolPaths(tool, input)) {
293    const path = normalize(p, ctx.cwd, ctx.home)
294    if (!isInsideAny(path, ctx.roots)) return outsideReason(path, ctx, '')
295  }
296  return undefined
297}
298
hooks/offer.ts 67 lines
1// What the model is offered: agents of packs this project has not enabled
2// (agentOffer), and heavy MCP tools moved behind ToolSearch (deferHeavyTools).
3// Pure: register.ts reads project.json and settings through `$`.
4
5import type { ProjectConfig } from './roots'
6
7/** An MCP tool description longer than this (in characters) is "heavy". */
8export const HEAVY_DESCRIPTION = 2048
9
10const SELF = 'guard-pack'
11
12const pluginName = (key: string): string => key.split('@')[0] ?? key
13
14/** Names a value lists: an array of strings, or an object's keys set to true. */
15const enabledNames = (v: unknown): string[] => {
16  if (Array.isArray(v)) return v.filter((s): s is string => typeof s === 'string').map(pluginName)
17  if (typeof v === 'object' && v !== null) {
18    return Object.entries(v as Readonly<Record<string, unknown>>)
19      .filter(([, on]) => on === true)
20      .map(([k]) => pluginName(k))
21  }
22  return []
23}
24
25export type PackPolicy = {
26  /** False when the project declares nothing: then every agent stays offered. */
27  readonly isDeclared: boolean
28  readonly enabled: ReadonlySet<string>
29}
30
31/**
32 * The packs this project enables: project.json `enabledPacks` (and an
33 * `enabledPlugins` there, if a project keeps one), plus the project and local
34 * settings' `enabledPlugins`. User-level settings are left out on purpose:
35 * a pack enabled for every project is not this project's choice.
36 */
37export const packPolicy = (
38  project: ProjectConfig | undefined,
39  projectSettingsPlugins: unknown,
40  localSettingsPlugins: unknown,
41): PackPolicy => {
42  const sources: unknown[] = [
43    project?.json.enabledPacks,
44    project?.json.enabledPlugins,
45    projectSettingsPlugins,
46    localSettingsPlugins,
47  ]
48  const isDeclared = project !== undefined || sources.some(s => s !== undefined)
49  return { isDeclared, enabled: new Set(sources.flatMap(enabledNames)) }
50}
51
52/** The pack an agent type belongs to, when it is a `<name>-pack` plugin's. */
53export const packOf = (agent: string, provider: string | undefined): string | undefined => {
54  const i = agent.indexOf(':')
55  const name = provider !== undefined && provider !== 'engine' ? provider : i > 0 ? agent.slice(0, i) : undefined
56  return name !== undefined && name.endsWith('-pack') ? name : undefined
57}
58
59export const isOffered = (policy: PackPolicy, agent: string, provider: string | undefined): boolean => {
60  if (!policy.isDeclared) return true
61  const pack = packOf(agent, provider)
62  return pack === undefined || pack === SELF || policy.enabled.has(pack)
63}
64
65export const isHeavyMcpTool = (tool: string, providerPlugin: string, description: string): boolean =>
66  (tool.startsWith('mcp__') || providerPlugin.startsWith('mcp:')) && description.length > HEAVY_DESCRIPTION
67
hooks/patterns.ts 67 lines
1// Compiles the pattern files shipped in hooks/patterns/. They are JSON so the
2// Go side can pin them to its own lists (parity tests); a hooks module may
3// only import code, so register.ts reads them through `$.fs` on session.start.
4
5export type SecretRule = {
6  readonly name: string
7  readonly re: RegExp
8  /** Replacement template: `$1` keeps a captured prefix, `{mark}` is the mark. */
9  readonly replace: string
10}
11
12export type ProdPattern = {
13  readonly source: string
14  readonly tool: string
15  /** Lower-cased argument glob; undefined matches any call of the tool. */
16  readonly inner: string | undefined
17}
18
19const isRecord = (v: unknown): v is Record<string, unknown> =>
20  typeof v === 'object' && v !== null && !Array.isArray(v)
21
22export const compileSecrets = (raw: unknown): SecretRule[] => {
23  if (!Array.isArray(raw)) return []
24  const out: SecretRule[] = []
25  for (const entry of raw) {
26    if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.pattern !== 'string') continue
27    const flags = typeof entry.flags === 'string' ? entry.flags.replace(/g/g, '') : ''
28    try {
29      out.push({
30        name: entry.name,
31        re: new RegExp(entry.pattern, `g${flags}`),
32        replace: typeof entry.replace === 'string' ? entry.replace : '{mark}',
33      })
34    } catch {
35      // a pattern this engine cannot compile is skipped, the rest still apply
36    }
37  }
38  return out
39}
40
41// The approval-rule syntax: a literal tool name, then an optional (argGlob).
42const RULE = /^([A-Za-z][A-Za-z0-9_-]*)(?:\((.+)\))?$/
43
44export const parseProdPattern = (s: string): ProdPattern | undefined => {
45  const m = RULE.exec(s.trim())
46  if (m === null || m[1] === undefined || m[1] === 'AskUserQuestion') return undefined
47  return { source: s, tool: m[1], inner: m[2] === undefined || m[2] === '' ? undefined : m[2].toLowerCase() }
48}
49
50export const compileProd = (raw: unknown): ProdPattern[] =>
51  Array.isArray(raw)
52    ? raw
53        .filter((s): s is string => typeof s === 'string')
54        .map(parseProdPattern)
55        .filter((p): p is ProdPattern => p !== undefined)
56    : []
57
58/** A pattern file's text as JSON, or undefined when unreadable or malformed. */
59export const parseJson = (text: unknown): unknown => {
60  if (typeof text !== 'string') return undefined
61  try {
62    return JSON.parse(text)
63  } catch {
64    return undefined
65  }
66}
67
hooks/prodguard.ts 69 lines
1// Production-deploy guard: a tool call matching a production-deploy pattern
2// is put to the native ask dialog, whatever the permission mode allowed.
3// Additive to any shell hook or daemon guard doing the same; a deny beneath
4// stays a deny.
5//
6// Matching is a port of the daemon's approval-rule matcher: `Tool` or
7// `Tool(argGlob)`; the tool part exact, the glob over the tool's argument
8// string, case-insensitive, `*` any run of characters and nothing else
9// special. The shipped list is hooks/patterns/prod.json; the project's own
10// .claude/project.json approvals.prodDeployPatterns are added to it.
11
12import { type ProdPattern, compileProd } from './patterns'
13import type { ProjectConfig } from './roots'
14
15/** `*` matches any run of characters (including `/`, spaces); the rest is literal. */
16export const globMatch = (pattern: string, s: string): boolean => {
17  const parts = pattern.split('*')
18  if (parts.length === 1) return s === pattern
19  const first = parts[0] ?? ''
20  if (!s.startsWith(first)) return false
21  let rest = s.slice(first.length)
22  for (const mid of parts.slice(1, -1)) {
23    if (mid === '') continue
24    const i = rest.indexOf(mid)
25    if (i < 0) return false
26    rest = rest.slice(i + mid.length)
27  }
28  const last = parts[parts.length - 1] ?? ''
29  return last === '' || rest.endsWith(last)
30}
31
32// The tool → argument-field allow-list; a tool not listed has no argument
33// string, so a Tool(argGlob) pattern never matches it.
34const ARG_FIELDS: Readonly<Record<string, string>> = {
35  Bash: 'command',
36  Read: 'file_path',
37  Write: 'file_path',
38  Edit: 'file_path',
39  WebFetch: 'url',
40  Glob: 'pattern',
41  Grep: 'pattern',
42}
43
44export const argOf = (tool: string, input: unknown): string | undefined => {
45  const field = ARG_FIELDS[tool]
46  if (field === undefined || typeof input !== 'object' || input === null) return undefined
47  const v = (input as Readonly<Record<string, unknown>>)[field]
48  return typeof v === 'string' && v !== '' ? v : undefined
49}
50
51export const matchProd = (
52  patterns: readonly ProdPattern[],
53  tool: string,
54  input: unknown,
55): ProdPattern | undefined => {
56  const arg = argOf(tool, input)?.toLowerCase()
57  return patterns.find(p => p.tool === tool && (p.inner === undefined || (arg !== undefined && globMatch(p.inner, arg))))
58}
59
60/** The project's own approvals.prodDeployPatterns, added to the shipped list. */
61export const projectProdPatterns = (project: ProjectConfig | undefined): ProdPattern[] => {
62  const approvals = project?.json.approvals
63  if (typeof approvals !== 'object' || approvals === null) return []
64  return compileProd((approvals as Readonly<Record<string, unknown>>).prodDeployPatterns)
65}
66
67export const prodReason = (hit: ProdPattern): string =>
68  `guard-pack: production deploy (matches ${hit.source}); confirm it yourself`
69
hooks/redaction.ts 73 lines
1// Redaction: every secret-shaped value inside a tool_result block becomes
2// [REDACTED:<rule>] before the row is stored and before the model reads it.
3// Rows without a tool_result block, and every other block of a row, are
4// handed on untouched.
5
6import type { SecretRule } from './patterns'
7
8export const MARK_PREFIX = '[REDACTED:'
9
10/** Applies every rule in order; returns the new text and how many values it masked. */
11export const redactText = (text: string, rules: readonly SecretRule[]): { text: string; count: number } => {
12  let count = 0
13  let out = text
14  for (const rule of rules) {
15    const keepsPrefix = rule.replace.includes('$1')
16    out = out.replace(rule.re, (match: string, ...rest: unknown[]) => {
17      const prefix = keepsPrefix && typeof rest[0] === 'string' ? rest[0] : ''
18      if (match.slice(prefix.length).startsWith(MARK_PREFIX)) return match
19      count++
20      // replacer functions, so a `$` inside the kept prefix is never read as a pattern
21      return rule.replace.replace('{mark}', () => `${MARK_PREFIX}${rule.name}]`).replace('$1', () => prefix)
22    })
23  }
24  return { text: out, count }
25}
26
27type Block = Readonly<Record<string, unknown>>
28
29const redactResultContent = (
30  content: unknown,
31  rules: readonly SecretRule[],
32): { content: unknown; count: number } => {
33  if (typeof content === 'string') {
34    const r = redactText(content, rules)
35    return { content: r.text, count: r.count }
36  }
37  if (!Array.isArray(content)) return { content, count: 0 }
38  let count = 0
39  const next = content.map((b: unknown) => {
40    const block = b as Block
41    if (typeof block !== 'object' || block === null || block.type !== 'text' || typeof block.text !== 'string') {
42      return b
43    }
44    const r = redactText(block.text, rules)
45    count += r.count
46    return r.count === 0 ? b : { ...block, text: r.text }
47  })
48  return { content: count === 0 ? content : next, count }
49}
50
51/** The row's blocks with tool_result contents redacted; other blocks as they were. */
52export const redactBlocks = (
53  blocks: readonly unknown[],
54  rules: readonly SecretRule[],
55): { blocks: unknown[]; count: number } => {
56  let count = 0
57  const out = blocks.map((b: unknown) => {
58    const block = b as Block
59    if (typeof block !== 'object' || block === null || block.type !== 'tool_result') return b
60    const r = redactResultContent(block.content, rules)
61    count += r.count
62    return r.count === 0 ? b : { ...block, content: r.content }
63  })
64  return { blocks: out, count }
65}
66
67export const hasToolResult = (content: unknown): content is readonly unknown[] =>
68  Array.isArray(content) &&
69  content.some(b => typeof b === 'object' && b !== null && (b as Block).type === 'tool_result')
70
71export const statusLine = (total: number): string =>
72  `guard: ${total} secret${total === 1 ? '' : 's'} redacted`
73
hooks/roots.ts 120 lines
1// The session's allowed roots and the project config they come from. Pure:
2// register.ts gathers the facts through `$` on session.start and hands them
3// here as plain values.
4
5import { normalize, splitList } from './paths'
6
7export type ProjectConfig = {
8  /** The directory holding `.claude/project.json`. */
9  readonly dir: string
10  readonly json: Readonly<Record<string, unknown>>
11}
12
13export type GuardContext = {
14  readonly cwd: string
15  readonly root: string
16  readonly home: string | undefined
17  readonly project: ProjectConfig | undefined
18  /** Normalized absolute directories a file tool may touch. */
19  readonly roots: readonly string[]
20}
21
22export type GuardOptions = {
23  readonly isolation: boolean
24  readonly redaction: boolean
25  readonly prodGuard: boolean
26  readonly agentOffer: boolean
27  readonly deferHeavyTools: boolean
28  readonly extraRoots: readonly string[]
29}
30
31/** What session.start reads through `$`, every field optional but cwd. */
32export type SessionFacts = {
33  readonly cwd: string
34  readonly root?: string
35  readonly home?: string
36  readonly tmpdir?: string
37  readonly workspaceRoot?: string
38  readonly agentProject?: string
39  readonly project?: ProjectConfig
40}
41
42const flag = (value: unknown, fallback: boolean): boolean =>
43  typeof value === 'boolean' ? value : fallback
44
45export const parseOptions = (options: Readonly<Record<string, unknown>>): GuardOptions => ({
46  isolation: flag(options.isolation, true),
47  redaction: flag(options.redaction, true),
48  prodGuard: flag(options.prodGuard, true),
49  agentOffer: flag(options.agentOffer, false),
50  deferHeavyTools: flag(options.deferHeavyTools, false),
51  extraRoots: typeof options.extraRoots === 'string' ? splitList(options.extraRoots) : [],
52})
53
54const isRecord = (v: unknown): v is Record<string, unknown> =>
55  typeof v === 'object' && v !== null && !Array.isArray(v)
56
57/** A project.json's text as config, or undefined when it is not a JSON object. */
58export const parseProject = (dir: string, text: unknown): ProjectConfig | undefined => {
59  if (typeof text !== 'string') return undefined
60  try {
61    const json: unknown = JSON.parse(text)
62    return isRecord(json) ? { dir, json } : undefined
63  } catch {
64    return undefined
65  }
66}
67
68const strings = (v: unknown): string[] =>
69  Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.trim() !== '') : []
70
71const unique = (paths: readonly string[]): string[] => [...new Set(paths)]
72
73const nonEmpty = (s: string | undefined): s is string => s !== undefined && s.trim() !== ''
74
75/** Temp roots, spelled both ways macOS reaches them (`/tmp` → `/private/tmp`). */
76const tmpRoots = (tmpdir: string | undefined): string[] => {
77  const out = ['/tmp', '/private/tmp']
78  if (tmpdir !== undefined && tmpdir.startsWith('/')) {
79    out.push(tmpdir)
80    if (tmpdir.startsWith('/var/')) out.push(`/private${tmpdir}`)
81  }
82  return out
83}
84
85/**
86 * The roots: the session root and cwd; with a project.json its directory,
87 * every `repos[]` entry (relative to that directory) and the workspace
88 * `<AGENT_WORKSPACE_ROOT>/<AGENT_PROJECT or name>`; always `~/.claude`, the
89 * temp dirs and `extraRoots` (relative to the project directory).
90 */
91export const buildContext = (facts: SessionFacts, options: GuardOptions): GuardContext => {
92  const { cwd, home, project } = facts
93  const root = facts.root ?? cwd
94  const base = project?.dir ?? root
95  const at = (p: string, from: string): string => normalize(p, from, home)
96
97  const roots: string[] = [at(root, '/'), at(cwd, '/')]
98  if (project !== undefined) {
99    roots.push(at(project.dir, '/'))
100    for (const repo of strings(project.json.repos)) roots.push(at(repo, project.dir))
101  }
102  const name = nonEmpty(facts.agentProject)
103    ? facts.agentProject
104    : typeof project?.json.name === 'string'
105      ? project.json.name
106      : undefined
107  if (nonEmpty(facts.workspaceRoot) && nonEmpty(name)) roots.push(at(`${facts.workspaceRoot}/${name}`, base))
108  if (nonEmpty(home)) roots.push(`${at(home, '/')}/.claude`)
109  roots.push(...tmpRoots(facts.tmpdir).map(t => at(t, '/')))
110  for (const extra of options.extraRoots) roots.push(at(extra, base))
111
112  return { cwd, root, home, project, roots: unique(roots) }
113}
114
115/** "a, b, c (+N more)" for a deny reason. */
116export const describeRoots = (roots: readonly string[], max = 3): string => {
117  const shown = roots.slice(0, max).join(', ')
118  return roots.length > max ? `${shown} (+${roots.length - max} more)` : shown
119}
120
hooks/paths.ts 43 lines
1// Lexical path handling for the isolation guard. Nothing here touches the
2// file system: no symlink is resolved, so a path is judged by its spelling
3// after `~`, `.`, `..` and duplicate separators are folded.
4
5/** Joins `base` and a relative `path`, then folds `.`/`..`/`//` lexically. */
6export const normalize = (path: string, base: string, home: string | undefined): string => {
7  let p = path.trim()
8  if (home !== undefined && (p === '~' || p.startsWith('~/'))) p = home + p.slice(1)
9  if (!p.startsWith('/')) p = `${base.replace(/\/+$/, '')}/${p}`
10  const out: string[] = []
11  for (const seg of p.split('/')) {
12    if (seg === '' || seg === '.') continue
13    if (seg === '..') out.pop()
14    else out.push(seg)
15  }
16  return `/${out.join('/')}`
17}
18
19/**
20 * Whether `path` lies at or under `root`. Compared case-insensitively: on a
21 * case-insensitive volume `/Work/x` and `/work/x` are one file, and a false
22 * deny costs more than the miss this allows on a case-sensitive one.
23 */
24export const isUnder = (path: string, root: string): boolean => {
25  const p = path.toLowerCase()
26  const r = root.toLowerCase()
27  return r === '/' || p === r || p.startsWith(`${r}/`)
28}
29
30export const isInsideAny = (path: string, roots: readonly string[]): boolean =>
31  roots.some(root => isUnder(path, root))
32
33/** Splits a comma-separated option value, dropping blanks. */
34export const splitList = (value: string): string[] =>
35  value
36    .split(',')
37    .map(s => s.trim())
38    .filter(s => s !== '')
39
40/** Paths a command may always touch: the null device and the std streams. */
41export const isDevicePath = (path: string): boolean =>
42  /^\/dev\/(null|zero|stdin|stdout|stderr|tty|fd\/\d+)$/.test(path)
43