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…

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.
| Guard | Hook | What it does |
|---|---|---|
| Isolation | tool.call | Denies 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. |
| Redaction | session.append | Before 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 guard | tool.check | Sends 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 offer | agent.offer | Hides <name>-pack agents of packs this project has not enabled from the model. Off by default. |
| Heavy-tool deferral | tool.describe | Puts an MCP tool whose description is longer than 2048 characters behind ToolSearch. Off by default. |
These are read on session.start, which fires again on every reload:
$.session.root(), $.session.cwd());.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.
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.
userConfig)| Key | Type | Default | Effect |
|---|---|---|---|
isolation | boolean | true | The isolation guard. Off registers no tool.call hook. |
redaction | boolean | true | The redaction guard. Off registers no session.append hook. |
prodGuard | boolean | true | The production guard. Off registers no tool.check hook. |
agentOffer | boolean | false | Hide 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. |
deferHeavyTools | boolean | false | Defer MCP tools with a description over 2048 characters. |
extraRoots | string | "" | 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.
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
Everything here is additive, and nothing depends on core or on the daemon:
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.~, ., .. 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./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./…, ~, ~/…) 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.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.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.hooks/register.ts 155 lines1// 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}
155hooks/isolation.ts 298 lines1// 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}
298hooks/offer.ts 67 lines1// 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
67hooks/patterns.ts 67 lines1// 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}
67hooks/prodguard.ts 69 lines1// 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`
69hooks/redaction.ts 73 lines1// 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`
73hooks/roots.ts 120 lines1// 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}
120hooks/paths.ts 43 lines1// 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