Say which files a task may touch. Scope Cop flags Claude's edits outside that scope, tells Claude, or blocks them if you want.

Claude stays inside the scope you set, and asks before leaving it. Edits outside it are flagged, or blocked if you want.
A Claude Code mod. This page is the reference. For the suite, see the Notary README.
/plugin install scope-cop --marketplace candygoogy/notary
Needs Claude Code 2.1.287 or later. On an older version, Scope Cop shows one notice and stays off.
It is a guardrail, not a security boundary. Mods aren't sandboxed, and a shell command can write anywhere.
/scope src/auth/** tests/auth/**
Every prompt you send after it tells Claude the scope (*"for this task the user limited edits to src/auth/\*\*, tests/auth/\*\*. Ask before editing other files."*), so it survives a compacted conversation. It holds until /scope off, /clear or the end of the session.
| Command | What it does |
|---|---|
/scope <pattern>... | Sets the scope. |
/scope add <pattern>... | Widens it. |
/scope remove <pattern>... | Drops patterns. Removing the last one clears the scope. |
/scope off | Clears it. |
/scope mode A, B or C | Sets the mode (below). |
/scope | Opens the Scope pane. |
Patterns are gitignore-style and start at the repository root (or the session's folder outside git):
* and ? match inside one folder, ** across folders: src/*.ts, **/*.test.ts.src/auth is the same as src/auth/**.!pattern excludes: src/ !src/generated/. The last pattern that matches a file decides.\ works as well as /... are refused: a scope is always inside the project. Wrap a pattern with spaces in quotes.Always allowed, whatever the scope: Claude's own files (~/.claude: memory, plans, todos), temp folders outside the project, scratch folders anywhere (scratch, .scratch, *-scratch, tmp, .tmp, tmp-*, .tmp-*, and *.tmp.* files), screenshot, agent and cache folders anywhere (shots, .shots, screenshots, .agent, .cache; secret-looking files in them like .env*, *.pem, *.key and credentials* are still flagged), and lockfiles (package-lock.json, pnpm-lock.yaml, Cargo.lock, go.sum and the like).
A scope includes its own tests. With src/auth/**, test/auth/login.test.ts, tests/auth/x.ts and src/auth/__tests__/x.ts count as inside it. Other tests (test/db/..., e2e/checkout.spec.ts) don't: add them when a task needs them.
| Mode | Edit, Write, NotebookEdit outside the scope | A shell command that wrote outside the scope |
|---|---|---|
| A: flag | Runs. Shown on the band and in the pane. | Shown on the band and in the pane. |
| B: flag and tell Claude (default) | Runs. Claude is told on the edit's own result, once per file per turn. | Claude is told with your next prompt. |
| C: block | Refused before it runs. Claude reads why and that it should ask you. | Claude is told with your next prompt. A command whose write target is a literal absolute path outside the scope is refused before it runs. |
What Claude is told states facts and names the mod: *"Scope Cop (a mod the user installed): src/db/pool.ts is outside the scope the user set for this task (src/auth/\*\*). The edit went through. If it was not needed for the task, say so in your answer."* In testing, Claude read an instruction-like note in a tool result as untrusted and ignored it, while it acknowledged this wording.
Scope Cop only tightens. It never turns a refusal from your settings, another hook or the permission mode into an allow, and if Scope Cop itself fails, the edit goes ahead as if it weren't installed.
Subagents are covered: their edits go through the same checks.
Approved edits. When Claude stops at the scope's edge and names the file it would need ("this also needs src/api/auth.ts, which is outside your scope") and you go on, an edit to that path in your next turn is approved: in modes A and B it's listed in the pane as approved, stays off the band and isn't told to Claude (mode C still blocks it; use Allow file). Only paths named with their folder count, and only for that one turn.
While a scope is set, a line above the prompt shows it: Scope: src/auth/ · nothing outside [Clear]. After an edit outside it: Scope: src/auth/ · 1 edit outside: src/db/pool.ts [Allow file] [Details] [Dismiss]. Allow file adds that file to the scope. The list starts again at your next prompt.
The Scope pane (/scope, or Details) lists the patterns, each with Remove, the mode, and every edit outside the scope in this conversation, newest first.
A mod sees a shell command's text before it runs, and nothing about what the process wrote. So:
>, tee, sed -i, cp, mv, rm, touch, mkdir; Set-Content, Out-File, New-Item, Remove-Item, Copy-Item, Move-Item and their aliases) are checked when they are absolute paths. A relative path isn't judged there, because the shell's folder can differ from the session's after an earlier cd.$(...), scripts and interpreters (python, node, npm run), and relative paths after a cd.| Setting | Command | Default |
|---|---|---|
| Mode | /scope mode A, B or C (holds at once for the conversation, and is saved as the setting when the session has its /config row) | B |
| On or off | /config | on |
/resume."deny": ["Edit(src/db/)"] in settings) block folders for good and stay in force with Scope Cop. They can't say "only this folder": Edit() with Edit(!src/auth/**) refuses every edit, src/auth included (checked on Claude Code 2.1.310).hooks/register.tsx 579 lines1import type { EngineInterface as Engine, MatchedHook, Register } from 'claude-code'
2import { update } from 'claude-code'
3
4import { lastSeq, shellEditsSince } from './shared/changes-index.ts'
5import { compilePatterns, type Pattern } from './shared/glob.ts'
6import { waitingNote } from './shared/pane.ts'
7import { ARGUMENT_HINT, DESCRIPTION, USAGE, parseScopeCommand } from './core/commands.ts'
8import { MODE_TEXT, badPatternsReply, denyReason, scopeChangedReply, scopeClearedReply, scopeLine, scopeSetReply, tellLater, tellNow } from './core/copy.ts'
9import { isApproved, namedAtTurnStart, namedOutside } from './core/named.ts'
10import { classify, decide, type Ctx, type Mode, type Where } from './core/scope.ts'
11import { shellWrites } from './core/shell.ts'
12import { bandView } from './ui/band.tsx'
13import { paneView } from './ui/pane.tsx'
14
15// Wiring only (design note §3-§6): events in, core calls, state and rows out.
16// Scope Cop only tightens: a guard returns the verdict beneath or a deny,
17// never an allow, and anything that fails lets the call through.
18
19const MIN_VERSION = [2, 1, 287] as const
20const LOG_LIMIT = 200
21const OUTSIDE_LIMIT = 50
22const TOLD_LIMIT = 200
23const PROMPT_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
24const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
25const SHELL_TOOLS: readonly string[] = ['Bash', 'PowerShell']
26
27// State references: `plugin` and `key` must be literals in a const of this file.
28const SCOPE = { plugin: 'scope-cop', key: 'scope' } as const
29const SESSION = { plugin: 'scope-cop', key: 'session' } as const
30const CURRENT_TURN = { plugin: 'scope-cop', key: 'currentTurn' } as const
31const OUTSIDE = { plugin: 'scope-cop', key: 'outside' } as const
32const LOG = { plugin: 'scope-cop', key: 'log' } as const
33const TOLD = { plugin: 'scope-cop', key: 'told' } as const
34const SHELL_SEEN = { plugin: 'scope-cop', key: 'shellSeen' } as const
35const PENDING_TELL = { plugin: 'scope-cop', key: 'pendingTell' } as const
36const INACTIVE = { plugin: 'scope-cop', key: 'inactive' } as const
37// `/scope mode`, for this conversation: over the setting, which can't always be saved.
38const MODE = { plugin: 'scope-cop', key: 'mode' } as const
39// Files the last answer named outside the scope: approved in the next turn.
40const NAMED = { plugin: 'scope-cop', key: 'named' } as const
41// Changes' index, read only: its shell writes (shared/changes-index.ts).
42const CHANGES_INDEX = { plugin: 'changes', key: 'index' } as const
43
44const PANE = { id: 'scope', title: 'Scope' } as const
45
46type Options = { enabled: boolean; mode: Mode }
47// `sweep`: a change during the turn that no call explains (a shell command,
48// or the person's own editor): logged in the pane only, never told to Claude.
49// `approved`: a file the last answer named, edited in the next turn: in the
50// pane only, never told.
51type Flag = { at: number; turnId: string; path: string; rel: string | null; tool: string; via: 'tool' | 'shell' | 'sweep'; agentId?: string; outcome: 'flagged' | 'told' | 'denied' | 'approved' }
52type Session = { cwd: string; root: string; claudeDir: string; tempDirs: string[]; isWindows: boolean }
53type Active = { patterns: string[]; compiled: Pattern[]; session: Session; mode: Mode }
54
55export const register: Register = (on, options) => {
56 const opts: Options = {
57 enabled: options.enabled !== false,
58 mode: options.mode === 'A' || options.mode === 'C' ? options.mode : 'B',
59 }
60
61 on('session.start', async ($, e, next) => {
62 const r = await next(e)
63 try {
64 await $.command.register({ name: 'scope', description: DESCRIPTION, argumentHint: ARGUMENT_HINT })
65 } catch (error) {
66 debug($, 'session.start', error)
67 }
68 if (!opts.enabled) return r
69 try {
70 const { base, version } = await $.session.version()
71 if (isOlder(base ?? version, MIN_VERSION)) {
72 await $.state.set(INACTIVE, true)
73 $.ui.toast(`Scope Cop needs Claude Code ${MIN_VERSION.join('.')} or later; it is off for this session.`)
74 return r
75 }
76 await $.state.set(SESSION, await sessionOf($))
77 } catch (error) {
78 debug($, 'session.start', error)
79 }
80 return r
81 })
82
83 on('turn.start', async ($, e, next) => {
84 const r = await next(e)
85 try {
86 if (opts.enabled) await $.state.set(CURRENT_TURN, e.turnId)
87 if (opts.enabled && e.agentId === undefined) await update($, NAMED, (named = null) => namedAtTurnStart(named, e.turnId))
88 } catch (error) {
89 debug($, 'turn.start', error)
90 }
91 return r
92 })
93
94 // The guard: a file tool, or (mode C) a shell command with a literal target,
95 // outside the scope is denied before it runs. Tighten only, fail open.
96 on('tool.check', async ($, e, next) => {
97 const r = await next(e)
98 if (r.decision === 'deny') return r
99 const isEdit = EDIT_TOOLS.includes(e.tool)
100 if (!isEdit && !SHELL_TOOLS.includes(e.tool)) return r
101 const active = await activeScope($, opts)
102 if (active === null || (!isEdit && active.mode !== 'C')) return r
103 const input = (e.input ?? {}) as Record<string, unknown>
104 if (isEdit) {
105 const path = await absolute($, active.session, String(input.file_path ?? input.notebook_path ?? ''))
106 if (path === '') return r
107 const where = classify(path, ctxOf(active.session), active.compiled)
108 if (!decide(active.mode, 'file', where).deny) return r
109 const shown = shownPath(where, path)
110 await flag($, { path: shown, rel: relOf(where), tool: e.tool, via: 'tool', agentId: e.agentId, outcome: 'denied' })
111 return { decision: 'deny', reason: denyReason(shown, active.patterns) }
112 }
113 const writes = shellWrites(String(input.command ?? ''), e.tool === 'PowerShell' ? 'PowerShell' : 'Bash')
114 // Only absolute targets: the shell's own directory can differ from the
115 // session's (a `cd` in an earlier call), so a relative one is not judged
116 // here; Changes still sees what it wrote.
117 for (const target of writes.targets.filter(isAbsolute)) {
118 const path = await realPathOf($, target)
119 const where = classify(path, ctxOf(active.session), active.compiled)
120 if (!decide(active.mode, 'shell-before', where).deny) continue
121 const shown = shownPath(where, path)
122 await flag($, { path: shown, rel: relOf(where), tool: e.tool, via: 'shell', agentId: e.agentId, outcome: 'denied' })
123 return { decision: 'deny', reason: denyReason(shown, active.patterns) }
124 }
125 return r
126 }).catch(($, e, next) => next(e))
127
128 // An edit outside the scope that landed: flagged, and in mode B Claude is
129 // told on the call's own result, once per file per turn. A file the last
130 // answer named is approved instead: in the pane only.
131 on('tool.call', async ($, e, next) => {
132 const r = await next(e)
133 if (!EDIT_TOOLS.includes(e.tool)) return r
134 if (('deny' in r && r.deny !== undefined) || r.isError === true) return r
135 if ((r.result as { staged?: boolean } | undefined)?.staged === true) return r
136 try {
137 const active = await activeScope($, opts)
138 if (active === null) return r
139 const call = e as unknown as { file_path?: string; notebook_path?: string; agentId?: string }
140 const path = await absolute($, active.session, String(call.file_path ?? call.notebook_path ?? ''))
141 if (path === '') return r
142 const where = classify(path, ctxOf(active.session), active.compiled)
143 const action = decide(active.mode, 'file', where)
144 if (!action.flag) return r
145 const shown = shownPath(where, path)
146 if (await approved($, active, where)) {
147 await flag($, { path: shown, rel: relOf(where), tool: e.tool, via: 'tool', agentId: call.agentId, outcome: 'approved' }, false)
148 return r
149 }
150 const tell = action.tell === 'now' && (await firstTell($, shown))
151 await flag($, { path: shown, rel: relOf(where), tool: e.tool, via: 'tool', agentId: call.agentId, outcome: tell ? 'told' : 'flagged' })
152 if (tell) return { ...r, context: [...(r.context ?? []), tellNow(shown, active.patterns)] }
153 } catch (error) {
154 debug($, 'tool.call', error)
155 }
156 return r
157 }).catch(($, e, next) => next(e))
158
159 // Shell writes, from Changes' index, at the end of each main-loop turn; then
160 // the files the answer named outside the scope, for the next turn.
161 on('turn.complete', async ($, e, next) => {
162 const r = await next(e)
163 if (e.agentId !== undefined) return r
164 try {
165 await judgeShellWrites($, opts)
166 const active = await activeScope($, opts)
167 const paths = active === null ? [] : namedOutside(e.answer, active.session.cwd, ctxOf(active.session), active.compiled)
168 await $.state.set(NAMED, paths.length === 0 ? null : { paths, turnId: null })
169 } catch (error) {
170 debug($, 'turn.complete', error)
171 }
172 return r
173 })
174
175 // Each prompt carries the scope (it survives compaction) and, once, what
176 // shell commands wrote outside it. The band's list starts again.
177 on('prompt.submit', async ($, e, next) => {
178 if (!PROMPT_ORIGINS.includes(e.origin.kind)) return next(e)
179 const context: string[] = []
180 try {
181 const active = await activeScope($, opts)
182 if (active !== null) {
183 await judgeShellWrites($, opts)
184 context.push(scopeLine(active.patterns))
185 await update($, PENDING_TELL, (pending = null) => {
186 if (pending !== null) context.push(pending)
187 return null
188 })
189 await $.state.set(OUTSIDE, [])
190 }
191 } catch (error) {
192 debug($, 'prompt.submit', error)
193 }
194 return next(context.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...context] })
195 }).catch(($, e, next) => next(e))
196
197 // A new conversation (/clear, or the session's end) starts with no scope.
198 on('session.end', async ($, e, next) => {
199 const r = await next(e)
200 try {
201 await resetConversation($)
202 } catch (error) {
203 debug($, 'session.end', error)
204 }
205 return r
206 })
207
208 on('command.run', { command: 'scope' }, async ($, e) => {
209 try {
210 return await runCommand($, opts, e.args)
211 } catch (error) {
212 debug($, 'command', error)
213 return { text: 'Scope Cop could not change the scope. Try again; the debug log has the details.' }
214 }
215 })
216
217 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => (opts.enabled ? renderBand($, opts, e, next) : next(e)))
218
219 on('ui.render', { component: 'Pane', requestId: 'scope' }, ($, e, next) => (opts.enabled ? renderPane($, opts, e, next) : next(e)))
220
221 // The band's [Details] shows only while the pane is shut.
222 on('ui.close', { id: 'scope' }, async ($, e, next) => {
223 const r = await next(e)
224 $.ui.invalidate('ui.render')
225 return r
226 }).catch(($, e, next) => next(e))
227}
228
229type BandRender = Parameters<MatchedHook<'ui.render', { component: 'AbovePrompt' }>>
230type PaneRender = Parameters<MatchedHook<'ui.render', { component: 'Pane'; requestId: 'scope' }>>
231
232async function runCommand($: Engine, opts: Options, args: string): Promise<{ text: string }> {
233 const command = parseScopeCommand(args)
234 if (command.kind === 'error') return { text: `${command.message}\n${USAGE}` }
235 if (!opts.enabled) return { text: 'Scope Cop is turned off. Turn it on in /config.' }
236 if (command.kind === 'show') return { text: (await openPane($)) ?? 'Scope pane opened.' }
237 if (command.kind === 'mode') {
238 // Holds for this conversation at once; saved as the setting where the
239 // session has the row (a headless session has no plugin rows).
240 await $.state.set(MODE, command.mode)
241 $.ui.invalidate('ui.render')
242 const saved = await saveMode($, command.mode)
243 return { text: `Scope Cop mode ${MODE_TEXT[command.mode]}.${saved ? '' : ' This holds for this conversation; the setting could not be saved here (change it in /config).'}` }
244 }
245 if (command.kind === 'off') {
246 await $.state.set(SCOPE, null)
247 await $.state.set(NAMED, null)
248 await $.state.set(OUTSIDE, [])
249 $.ui.invalidate('ui.render')
250 return { text: scopeClearedReply() }
251 }
252 const { value: session = null } = await $.state.get(SESSION)
253 const isWindows = session?.isWindows ?? false
254 const { value: current = null } = await $.state.get(SCOPE)
255 const before = current?.patterns ?? []
256 const wanted = command.patterns.map(p => p.trim())
257 let next: string[]
258 if (command.kind === 'remove') {
259 const missing = wanted.filter(p => !before.includes(p))
260 if (missing.length > 0) return { text: `Not in the scope: ${missing.join(', ')}. The scope is ${before.length === 0 ? 'not set' : before.join(', ')}.` }
261 next = before.filter(p => !wanted.includes(p))
262 } else {
263 const compiled = compilePatterns(wanted, isWindows)
264 if ('errors' in compiled) return { text: badPatternsReply(compiled.errors) }
265 next = command.kind === 'add' ? [...before, ...wanted.filter(p => !before.includes(p))] : [...new Set(wanted)]
266 }
267 if (next.length === 0) {
268 await $.state.set(SCOPE, null)
269 await $.state.set(NAMED, null)
270 $.ui.invalidate('ui.render')
271 return { text: scopeClearedReply() }
272 }
273 // Shell writes made before this scope are not judged against it.
274 if (current === null) await $.state.set(SHELL_SEEN, lastSeq(await changesIndex($)))
275 await $.state.set(SCOPE, { patterns: next, setAt: await $.clock.now() })
276 // Names were judged against the old scope.
277 await $.state.set(NAMED, null)
278 $.ui.invalidate('ui.render')
279 return { text: command.kind === 'set' ? scopeSetReply(next) : scopeChangedReply(next) }
280}
281
282async function saveMode($: Engine, mode: Mode): Promise<boolean> {
283 try {
284 const result = await $.config.set({ key: 'scope-cop.mode', value: mode })
285 return !('deny' in result && result.deny !== undefined)
286 } catch (error) {
287 debug($, 'mode', error)
288 return false
289 }
290}
291
292// The scope and the session, when Scope Cop is on and a scope is set.
293async function activeScope($: Engine, opts: Options): Promise<Active | null> {
294 if (!opts.enabled) return null
295 const { value: inactive = false } = await $.state.get(INACTIVE)
296 if (inactive) return null
297 const { value: scope = null } = await $.state.get(SCOPE)
298 if (scope === null || scope.patterns.length === 0) return null
299 const { value: stored = null } = await $.state.get(SESSION)
300 const session = stored ?? (await sessionOf($))
301 if (stored === null) await $.state.set(SESSION, session)
302 const compiled = compilePatterns(scope.patterns, session.isWindows)
303 if ('errors' in compiled) return null
304 const { value: mode = null } = await $.state.get(MODE)
305 return { patterns: scope.patterns, compiled: compiled.patterns, session, mode: mode ?? opts.mode }
306}
307
308// Files shell commands changed, as Changes recorded them (inside git only),
309// since the last look: flagged, and queued for Claude in modes B and C, or
310// approved when the last answer named them. A sweep is never approved.
311async function judgeShellWrites($: Engine, opts: Options): Promise<void> {
312 const active = await activeScope($, opts)
313 if (active === null) return
314 const index = await changesIndex($)
315 const { value: seen = 0 } = await $.state.get(SHELL_SEEN)
316 const edits = shellEditsSince(index, seen)
317 const newest = lastSeq(index)
318 if (newest > seen) await $.state.set(SHELL_SEEN, newest)
319 const tell: string[] = []
320 for (const edit of edits) {
321 const path = await realPathOf($, edit.path)
322 const where = classify(path, ctxOf(active.session), active.compiled)
323 const action = decide(active.mode, 'shell', where)
324 if (!action.flag) continue
325 const shown = shownPath(where, path)
326 if (edit.unattributed) {
327 await flag($, { path: shown, rel: relOf(where), tool: edit.tool, via: 'sweep', agentId: edit.agentId, outcome: 'flagged' }, false)
328 continue
329 }
330 if (await approved($, active, where)) {
331 await flag($, { path: shown, rel: relOf(where), tool: edit.tool, via: 'shell', agentId: edit.agentId, outcome: 'approved' }, false)
332 continue
333 }
334 await flag($, { path: shown, rel: relOf(where), tool: edit.tool, via: 'shell', agentId: edit.agentId, outcome: action.tell === null ? 'flagged' : 'told' })
335 if (action.tell !== null && !tell.includes(shown)) tell.push(shown)
336 }
337 if (tell.length > 0) await $.state.set(PENDING_TELL, tellLater(tell, active.patterns))
338}
339
340// Changes' index, or undefined: Changes may not be installed, and the value
341// is never trusted (shared/changes-index.ts reads it defensively).
342async function changesIndex($: Engine): Promise<never | undefined> {
343 try {
344 return (await $.state.get(CHANGES_INDEX))?.value as never
345 } catch (error) {
346 debug($, 'changes index', error)
347 return undefined
348 }
349}
350
351// Whether an edit outside the scope is one the last answer named (core/named.ts).
352async function approved($: Engine, active: Active, where: Where): Promise<boolean> {
353 const { value: named = null } = await $.state.get(NAMED)
354 return isApproved(named, active.mode, relOf(where), active.session.isWindows)
355}
356
357// Logged for the pane; on the band too unless `band` is false.
358async function flag($: Engine, f: Omit<Flag, 'at' | 'turnId'>, band = true): Promise<void> {
359 const at = await $.clock.now()
360 const { value: turnId = null } = await $.state.get(CURRENT_TURN)
361 const entry: Flag = { ...f, at, turnId: turnId ?? '' }
362 if (entry.agentId === undefined) delete entry.agentId
363 await update($, LOG, (log = []) => [...log, entry].slice(-LOG_LIMIT))
364 if (band) await update($, OUTSIDE, (outside = []) => [...outside, entry].slice(-OUTSIDE_LIMIT))
365 $.ui.invalidate('ui.render')
366}
367
368// Mode B tells Claude about a file once per turn.
369async function firstTell($: Engine, path: string): Promise<boolean> {
370 const { value: turnId = null } = await $.state.get(CURRENT_TURN)
371 const key = `${turnId ?? ''}|${path}`
372 let first = false
373 await update($, TOLD, (told = []) => {
374 if (told.includes(key)) return told
375 first = true
376 return [...told, key].slice(-TOLD_LIMIT)
377 })
378 return first
379}
380
381// The band: one row while a scope is set, then whatever is beneath it.
382async function renderBand($: Engine, opts: Options, e: BandRender[1], next: BandRender[2]) {
383 if (e.component !== 'AbovePrompt' || e.props.hasSurvey) return next(e)
384 const active = await activeScope($, opts)
385 if (active === null) return next(e)
386 const { value: outside = [] } = await $.state.get(OUTSIDE)
387 const newest = [...outside].reverse().find(f => f.rel !== null) ?? null
388 const elements = $.ui.resolve(e)
389 const mine = bandView(elements, active.patterns, outside, {
390 onAllow: newest === null || newest.rel === null ? null : () => void allowFile($, newest.rel!),
391 onDetails: (await paneIsUp($)) ? null : () => void openDetails($),
392 onDismiss: () => void dismissOutside($),
393 onClear: () => void clearScope($),
394 })
395 if (e.props.maxRows < 2) return mine
396 const { Box } = elements
397 const below = await next(e)
398 return (
399 <Box key="scope-cop-above-prompt" flexDirection="column">
400 {mine}
401 {below}
402 </Box>
403 )
404}
405
406async function renderPane($: Engine, opts: Options, e: PaneRender[1], next: PaneRender[2]) {
407 if (e.component !== 'Pane' || e.requestId !== PANE.id) return next(e)
408 const { value: scope = null } = await $.state.get(SCOPE)
409 const { value: log = [] } = await $.state.get(LOG)
410 const { value: mode = null } = await $.state.get(MODE)
411 return paneView($.ui.resolve(e), scope?.patterns ?? null, mode ?? opts.mode, log, await $.clock.now(), pattern => void removePattern($, pattern))
412}
413
414// [Allow file]: the file joins the scope, and leaves the band's list.
415async function allowFile($: Engine, rel: string): Promise<void> {
416 try {
417 await update($, SCOPE, (scope = null) => (scope === null || scope.patterns.includes(rel) ? scope : { ...scope, patterns: [...scope.patterns, rel] }))
418 await update($, OUTSIDE, (outside = []) => outside.filter(f => f.rel !== rel))
419 await $.state.set(NAMED, null)
420 $.ui.invalidate('ui.render')
421 } catch (error) {
422 debug($, 'allow', error)
423 }
424}
425
426async function removePattern($: Engine, pattern: string): Promise<void> {
427 try {
428 await update($, SCOPE, (scope = null) => {
429 if (scope === null) return null
430 const patterns = scope.patterns.filter(p => p !== pattern)
431 return patterns.length === 0 ? null : { ...scope, patterns }
432 })
433 await $.state.set(NAMED, null)
434 $.ui.invalidate('ui.render')
435 } catch (error) {
436 debug($, 'remove', error)
437 }
438}
439
440async function dismissOutside($: Engine): Promise<void> {
441 await $.state.set(OUTSIDE, [])
442 $.ui.invalidate('ui.render')
443}
444
445async function clearScope($: Engine): Promise<void> {
446 await $.state.set(SCOPE, null)
447 await $.state.set(NAMED, null)
448 await $.state.set(OUTSIDE, [])
449 $.ui.invalidate('ui.render')
450}
451
452async function openPane($: Engine): Promise<string | null> {
453 const opened = await $.ui.open({ id: PANE.id, title: PANE.title })
454 $.ui.invalidate('ui.render')
455 return waitingNote(PANE.title, opened)
456}
457
458async function openDetails($: Engine): Promise<void> {
459 const note = await openPane($)
460 if (note !== null) $.ui.toast(note)
461}
462
463async function paneIsUp($: Engine): Promise<boolean> {
464 try {
465 return (await $.ui.panes()).some(pane => pane.id === PANE.id && pane.isPlaced)
466 } catch (error) {
467 debug($, 'panes', error)
468 return false
469 }
470}
471
472// Where the patterns are anchored (the repository root, else the session's
473// folder), and the folders that are always allowed, all as real paths.
474async function sessionOf($: Engine): Promise<Session> {
475 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
476 const cwd = slashes(await realPathOf($, await $.session.cwd()))
477 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
478 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === '' ? '' : `${slashes(home).replace(/\/$/, '')}/.claude`)
479 const temps = [await $.env.get('TEMP'), await $.env.get('TMP'), await $.env.get('TMPDIR'), isWindows ? undefined : '/tmp']
480 const tempDirs: string[] = []
481 for (const dir of temps) {
482 if (dir === undefined || dir === '') continue
483 const real = slashes(await realPathOf($, dir)).replace(/\/$/, '')
484 if (!tempDirs.includes(real)) tempDirs.push(real)
485 }
486 return {
487 cwd,
488 root: await repoRootOf($, cwd),
489 claudeDir: config === '' ? '' : slashes(await realPathOf($, config)).replace(/\/$/, ''),
490 tempDirs,
491 isWindows,
492 }
493}
494
495// The nearest folder at or above the session's with a `.git`, else the session's.
496async function repoRootOf($: Engine, cwd: string): Promise<string> {
497 let dir = cwd.replace(/\/+$/, '')
498 for (let i = 0; i < 64 && dir !== ''; i++) {
499 if (await $.fs.exists(`${dir}/.git`)) return dir
500 const up = dir.slice(0, dir.lastIndexOf('/'))
501 if (up === dir || up === '' || /^[A-Za-z]:$/.test(dir)) break
502 dir = up
503 }
504 return cwd
505}
506
507function ctxOf(session: Session): Ctx {
508 return { root: session.root, claudeDir: session.claudeDir, tempDirs: session.tempDirs, isWindows: session.isWindows }
509}
510
511// An absolute, real path for a tool's file argument (relative ones are under
512// the session's folder).
513async function absolute($: Engine, session: Session, path: string): Promise<string> {
514 if (path === '') return ''
515 const full = isAbsolute(path) ? path : `${session.cwd}/${path}`
516 return realPathOf($, full)
517}
518
519// A path as the file system names it (8.3 names expanded, links and `..`
520// resolved, the real case), with forward slashes. A file that doesn't exist
521// yet takes its nearest existing folder's real path.
522async function realPathOf($: Engine, path: string): Promise<string> {
523 const normal = slashes(path)
524 const parts = normal.split('/')
525 for (let keep = parts.length; keep > 0; keep--) {
526 const head = parts.slice(0, keep).join('/')
527 if (head === '' || /^[A-Za-z]:$/.test(head)) break
528 try {
529 const real = (await $.fs.stat(head, { resolve: true })).realPath
530 if (real !== undefined) return [slashes(real), ...parts.slice(keep)].join('/')
531 } catch {
532 // Not there yet: try the folder above.
533 }
534 }
535 return normal
536}
537
538function isAbsolute(path: string): boolean {
539 return /^(?:[A-Za-z]:[\\/]|[\\/])/.test(path)
540}
541
542function slashes(path: string): string {
543 return path.replace(/\\/g, '/')
544}
545
546function relOf(where: Where): string | null {
547 return where.kind === 'out' ? where.rel : null
548}
549
550function shownPath(where: Where, path: string): string {
551 return where.kind === 'out' && where.rel !== null ? where.rel : path
552}
553
554function isOlder(version: string, min: readonly number[]): boolean {
555 const parts = version.split(/[.-]/).map(Number)
556 for (let i = 0; i < min.length; i++) {
557 const part = parts[i] ?? 0
558 if (Number.isNaN(part)) return false
559 if (part !== min[i]!) return part < min[i]!
560 }
561 return false
562}
563
564async function resetConversation($: Engine): Promise<void> {
565 await $.state.set(SCOPE, null)
566 await $.state.set(NAMED, null)
567 await $.state.set(CURRENT_TURN, null)
568 await $.state.set(OUTSIDE, [])
569 await $.state.set(LOG, [])
570 await $.state.set(TOLD, [])
571 await $.state.set(SHELL_SEEN, 0)
572 await $.state.set(PENDING_TELL, null)
573 await $.state.set(MODE, null)
574}
575
576function debug($: Engine, where: string, error: unknown): void {
577 $.ui.log(`scope-cop: ${where} failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
578}
579hooks/shared/changes-index.ts 65 lines1// GENERATED by scripts/sync-shared.mjs from /shared. Do not edit here.
2// Another mod's view of Changes' index (`{ plugin: 'changes', key: 'index' }`):
3// only the fields read here, read defensively, since the index belongs to
4// Changes and a mod can't trust another's shape. Pure.
5
6export type ChangesEditView = {
7 seq: number
8 turnId: string
9 toolUseId: string
10 tool: string
11 via: string
12 path: string
13 agentId?: string
14}
15
16export type ChangesIndexView = { edits: readonly ChangesEditView[] }
17
18export type ShellEdit = {
19 seq: number
20 turnId: string
21 toolUseId: string
22 tool: string
23 path: string
24 // Found by the turn's sweep: no single call explains it.
25 unattributed: boolean
26 agentId?: string
27}
28
29// Files that shell commands (or the turn's sweep) changed after `seq`, oldest
30// first. Changes records them only inside a git repository.
31export function shellEditsSince(index: ChangesIndexView | null | undefined, seq: number): ShellEdit[] {
32 const edits: unknown = (index as { edits?: unknown } | null | undefined)?.edits
33 if (!Array.isArray(edits)) return []
34 const out: ShellEdit[] = []
35 for (const e of edits as unknown[]) {
36 if (typeof e !== 'object' || e === null) continue
37 const r = e as Record<string, unknown>
38 if (typeof r.seq !== 'number' || r.seq <= seq || typeof r.path !== 'string') continue
39 if (r.via !== 'shell' && r.via !== 'unattributed') continue
40 out.push({
41 seq: r.seq,
42 turnId: String(r.turnId ?? ''),
43 toolUseId: String(r.toolUseId ?? ''),
44 tool: String(r.tool ?? ''),
45 path: r.path,
46 unattributed: r.via === 'unattributed',
47 ...(typeof r.agentId === 'string' ? { agentId: r.agentId } : {}),
48 })
49 }
50 return out.sort((a, b) => a.seq - b.seq)
51}
52
53// The newest seq in the index, of any edit: a scope set now judges only what
54// comes after it.
55export function lastSeq(index: ChangesIndexView | null | undefined): number {
56 const edits: unknown = (index as { edits?: unknown } | null | undefined)?.edits
57 if (!Array.isArray(edits)) return 0
58 let last = 0
59 for (const e of edits as unknown[]) {
60 const seq = (e as { seq?: unknown } | null)?.seq
61 if (typeof seq === 'number' && seq > last) last = seq
62 }
63 return last
64}
65hooks/shared/glob.ts 80 lines1// GENERATED by scripts/sync-shared.mjs from /shared. Do not edit here.
2// Gitignore-style patterns, anchored at a root: `*` and `?` stay inside one
3// folder, `**` crosses folders, a trailing `/` (or `/**`) means "under this
4// folder", a leading `!` excludes. A pattern also covers everything under a
5// folder it names. The last pattern that matches decides. Pure.
6
7export type Pattern = { source: string; negate: boolean; re: RegExp }
8
9export type PatternError = { pattern: string; error: string }
10
11export function parsePattern(text: string, isWindows: boolean): { pattern: Pattern } | PatternError {
12 const raw = text.trim()
13 let body = raw.replace(/\\/g, '/')
14 const negate = body.startsWith('!')
15 if (negate) body = body.slice(1)
16 if (body.startsWith('//')) return { pattern: raw, error: 'a network path is not a pattern under the project' }
17 if (/^[A-Za-z]:/.test(body)) return { pattern: raw, error: 'an absolute path is not a pattern under the project' }
18 body = body.replace(/^(\.\/|\/)+/, '')
19 const segments = body.split('/')
20 if (segments.includes('..')) return { pattern: raw, error: '`..` leaves the project' }
21 body = segments.filter(s => s !== '.').join('/')
22 let under = false
23 if (body.endsWith('/**')) {
24 body = body.slice(0, -3)
25 under = true
26 }
27 if (body.endsWith('/')) {
28 body = body.replace(/\/+$/, '')
29 under = true
30 }
31 if (body === '' && !under) return { pattern: raw, error: 'empty pattern' }
32 if (body === '' || body === '**') return { pattern: raw, error: 'the whole project is not a scope' }
33 const tail = under ? '/.+' : '(?:/.*)?'
34 const re = new RegExp(`^${toRegex(body)}${tail}$`, isWindows ? 'i' : '')
35 return { pattern: { source: raw, negate, re } }
36}
37
38export function compilePatterns(texts: readonly string[], isWindows: boolean): { patterns: Pattern[] } | { errors: PatternError[] } {
39 const patterns: Pattern[] = []
40 const errors: PatternError[] = []
41 for (const text of texts) {
42 const r = parsePattern(text, isWindows)
43 if ('error' in r) errors.push(r)
44 else patterns.push(r.pattern)
45 }
46 return errors.length > 0 ? { errors } : { patterns }
47}
48
49// `path` is relative to the root the patterns are anchored at.
50export function matchesAny(patterns: readonly Pattern[], path: string): boolean {
51 const rel = path.replace(/\\/g, '/').replace(/^(\.\/|\/)+/, '')
52 let inside = false
53 for (const p of patterns) if (p.re.test(rel)) inside = !p.negate
54 return inside
55}
56
57function toRegex(body: string): string {
58 let out = ''
59 let i = 0
60 while (i < body.length) {
61 if (body.startsWith('**/', i) && (i === 0 || body[i - 1] === '/')) {
62 out += '(?:.*/)?'
63 i += 3
64 } else if (body.startsWith('**', i)) {
65 out += '.*'
66 i += 2
67 } else if (body[i] === '*') {
68 out += '[^/]*'
69 i += 1
70 } else if (body[i] === '?') {
71 out += '[^/]'
72 i += 1
73 } else {
74 out += body[i]!.replace(/[.+^${}()|[\]\\]/g, '\\$&')
75 i += 1
76 }
77 }
78 return out
79}
80hooks/shared/pane.ts 14 lines1// GENERATED by scripts/sync-shared.mjs from /shared. Do not edit here.
2// What to tell the person when a pane they asked for opens but waits undrawn
3// (`$.ui.open` answered `isPlaced: false`): the engine's own reason, which
4// names the width floor and the width now, or the surface that places no
5// panes. Null once the pane is drawn, so nothing shows then.
6
7export type Opened = { isPlaced: true } | { isPlaced: false; reason: string }
8
9export function waitingNote(title: string, opened: Opened): string | null {
10 if (opened.isPlaced) return null
11 const why = opened.reason.trim().replace(/[\s.]+$/, '')
12 return why === '' ? `${title} pane is ready but not shown yet.` : `${title} pane is ready but not shown yet: ${why}.`
13}
14hooks/core/commands.ts 74 lines1// The /scope command's arguments. Pure: register.tsx checks the patterns
2// (shared/glob.ts parsePattern) and acts.
3//
4// /scope open the pane
5// /scope <pattern>... set the scope
6// /scope add <pattern>... widen it
7// /scope remove <pattern>... drop patterns
8// /scope off clear it
9// /scope mode A|B|C warn, warn and tell Claude, block
10
11import type { Mode } from './scope.ts'
12
13export type ScopeCommand =
14 | { kind: 'show' }
15 | { kind: 'set' | 'add' | 'remove'; patterns: string[] }
16 | { kind: 'off' }
17 | { kind: 'mode'; mode: Mode }
18 | { kind: 'error'; message: string }
19
20export function parseScopeCommand(args: string): ScopeCommand {
21 const words = split(args)
22 if (words === null) return { kind: 'error', message: 'A quote is not closed. Wrap a pattern with spaces in "quotes".' }
23 if (words.length === 0) return { kind: 'show' }
24 const [first = '', ...rest] = words
25 switch (first.toLowerCase()) {
26 case 'add':
27 case 'remove':
28 if (rest.length === 0) return { kind: 'error', message: `Usage: /scope ${first.toLowerCase()} <pattern>...` }
29 return { kind: first.toLowerCase() as 'add' | 'remove', patterns: rest }
30 case 'off':
31 if (rest.length > 0) return { kind: 'error', message: 'Usage: /scope off (no patterns).' }
32 return { kind: 'off' }
33 case 'mode': {
34 const mode = rest.length === 1 ? rest[0]!.toUpperCase() : ''
35 if (mode !== 'A' && mode !== 'B' && mode !== 'C') return { kind: 'error', message: 'Usage: /scope mode A, B or C (warn; warn and tell Claude; block).' }
36 return { kind: 'mode', mode }
37 }
38 default:
39 return { kind: 'set', patterns: words }
40 }
41}
42
43// Words split on whitespace; "double" or 'single' quotes keep spaces. Null
44// when a quote is left open.
45function split(args: string): string[] | null {
46 const words: string[] = []
47 let current = ''
48 let inWord = false
49 let quote: string | null = null
50 for (const ch of args) {
51 if (quote !== null) {
52 if (ch === quote) quote = null
53 else current += ch
54 } else if (ch === '"' || ch === "'") {
55 quote = ch
56 inWord = true
57 } else if (/\s/.test(ch)) {
58 if (inWord) words.push(current)
59 current = ''
60 inWord = false
61 } else {
62 current += ch
63 inWord = true
64 }
65 }
66 if (quote !== null) return null
67 if (inWord) words.push(current)
68 return words
69}
70
71export const ARGUMENT_HINT = '[<pattern>... | add <pattern>... | remove <pattern>... | off | mode A|B|C]'
72export const DESCRIPTION = 'Scope Cop: limit which files Claude edits in this conversation'
73export const USAGE = `Usage: /scope ${ARGUMENT_HINT}`
74hooks/core/copy.ts 85 lines1// What Claude and the user read. Pure.
2//
3// Claude-facing text states facts and names the mod: in testing, an
4// instruction-like line in a tool result was ignored as untrusted, while this
5// factual wording was acknowledged.
6
7const WHO = 'Scope Cop (a mod the user installed)'
8const MAX_PATTERNS = 5
9const MAX_FILES = 8
10
11export function listPatterns(patterns: readonly string[]): string {
12 return cut(patterns, MAX_PATTERNS)
13}
14
15// Mode B, on the result of an out-of-scope Edit, Write or NotebookEdit.
16export function tellNow(path: string, patterns: readonly string[]): string {
17 return `${WHO}: ${path} is outside the scope the user set for this task (${listPatterns(patterns)}). The edit went through. If it was not needed for the task, say so in your answer.`
18}
19
20// Mode C: the error Claude reads instead of the tool's result.
21export function denyReason(path: string, patterns: readonly string[]): string {
22 return `${WHO}: ${path} is outside the scope the user set for this task (${listPatterns(patterns)}). The edit was not made. Ask the user before editing outside the scope; they can widen it with /scope add.`
23}
24
25// Modes B and C: shell writes seen after the turn, on the next user prompt.
26export function tellLater(paths: readonly string[], patterns: readonly string[]): string {
27 return `${WHO}: in your last turn, shell commands changed files outside the scope the user set for this task (${listPatterns(patterns)}): ${cut(paths, MAX_FILES)}. If they were not needed for the task, say so.`
28}
29
30// On every user prompt while a scope is set, so it survives compaction.
31export function scopeLine(patterns: readonly string[]): string {
32 return `${WHO}: for this task the user limited edits to ${listPatterns(patterns)}. Ask before editing other files.`
33}
34
35// The /scope command's reply, which Claude reads too.
36export function scopeSetReply(patterns: readonly string[]): string {
37 return `Scope set: edit only ${listPatterns(patterns)}. Ask before touching other files.`
38}
39
40function cut(items: readonly string[], max: number): string {
41 const shown = items.length > max ? [...items.slice(0, max), `+${items.length - max} more`] : items
42 return shown.join(', ')
43}
44
45// ---------------------------------------------------------- the person's
46
47export type FlagView = { path: string; outcome: 'flagged' | 'told' | 'denied' | 'approved' }
48
49export function bandText(patterns: readonly string[], outside: readonly FlagView[]): string {
50 const scope = `Scope: ${listPatterns(patterns)}`
51 if (outside.length === 0) return `${scope} · nothing outside`
52 const paths = [...new Set(outside.map(f => f.path))]
53 const blocked = outside.every(f => f.outcome === 'denied')
54 const what = blocked ? 'blocked' : paths.length === 1 ? 'edit outside' : 'edits outside'
55 return `${scope} · ${paths.length} ${what}: ${cut(paths, 2)}`
56}
57
58export const OUTCOME_TEXT: Record<FlagView['outcome'], string> = {
59 flagged: 'flagged',
60 told: 'flagged, Claude told',
61 denied: 'blocked',
62 // Named in Claude's last answer, edited in the next turn: not flagged.
63 approved: 'approved',
64}
65
66export const MODE_TEXT = {
67 A: 'A: edits outside the scope are flagged',
68 B: 'B: edits outside the scope are flagged, and Claude is told',
69 C: 'C: edits outside the scope are blocked, and Claude is told why',
70} as const
71
72export function scopeClearedReply(): string {
73 return 'Scope cleared: Claude may edit any file again.'
74}
75
76export function scopeChangedReply(patterns: readonly string[]): string {
77 return patterns.length === 0 ? scopeClearedReply() : scopeSetReply(patterns)
78}
79
80export function badPatternsReply(errors: ReadonlyArray<{ pattern: string; error: string }>): string {
81 return `Scope not changed. ${errors.map(e => `"${e.pattern}": ${e.error}`).join('; ')}. Patterns are relative to the project root, like src/auth/**.`
82}
83
84export const NO_SCOPE_TEXT = 'No scope set. /scope src/auth/** limits Claude to that folder for this conversation.'
85hooks/core/named.ts 55 lines1// Approved edits. With a scope set, Claude stops at its edge and names the
2// file it would need ("this also needs `src/api/auth.ts`, which is outside
3// your scope"); the person says yes, and the next turn edits it. That edit is
4// approved: logged in the pane, not flagged. Pure: register.tsx keeps the
5// names in state and acts.
6
7import type { Pattern } from '../shared/glob.ts'
8import { classify, type Ctx, type Mode } from './scope.ts'
9
10// The files one answer named outside the scope, as paths under the root.
11// `turnId`: the turn they hold for, null until the next main-loop turn starts.
12export type Named = { paths: string[]; turnId: string | null }
13
14// A path token: an optional drive, then names joined by `/` or `\`. It must
15// start a word, so a URL's path is never one. Backticks, quotes, a line
16// number (`src/api/users.ts:38`) and brackets fall outside it.
17const TOKEN = /(?<![\w.@+~\-:/\\])(?:[A-Za-z]:)?[\w.@+~-]*(?:[\\/][\w.@+~-]+)+/g
18
19// The files `answer` names that are outside the scope, under the root, each
20// once, in order. Only a path with a folder and a file name with an extension
21// (or a leading dot, `.env`) counts; relative ones are under `cwd`, like a
22// tool's. A bare file name (`auth.ts`) names nothing: when the answer also
23// gives its folder, that path is already here. Paths with `..`, files outside
24// the project, in the scope or always allowed are left out.
25export function namedOutside(answer: string, cwd: string, ctx: Ctx, patterns: readonly Pattern[]): string[] {
26 const named: string[] = []
27 const fold = (s: string) => (ctx.isWindows ? s.toLowerCase() : s)
28 for (const [token] of answer.matchAll(TOKEN)) {
29 const parts = token.replace(/\\/g, '/').replace(/\.+$/, '').split('/')
30 if (parts.includes('..') || !/\.\w/.test(parts.at(-1) ?? '')) continue
31 const path = parts.filter((part, i) => part !== '.' && (part !== '' || i === 0)).join('/')
32 const full = /^(?:[A-Za-z]:)?\//.test(path) ? path : `${cwd.replace(/\/+$/, '')}/${path}`
33 const where = classify(full, ctx, patterns)
34 if (where.kind !== 'out' || where.rel === null) continue
35 const rel = where.rel
36 if (!named.some(p => fold(p) === fold(rel))) named.push(rel)
37 }
38 return named
39}
40
41// At a main-loop turn's start: names waiting for a turn hold for this one;
42// names an earlier turn held lapse, so they never outlive the next turn.
43export function namedAtTurnStart(named: Named | null, turnId: string): Named | null {
44 return named !== null && named.turnId === null ? { ...named, turnId } : null
45}
46
47// An edit outside the scope, at `rel` under the root, is approved when the
48// last answer named it and this is the turn after. Not in mode C, where
49// approval is the band's [Allow file].
50export function isApproved(named: Named | null, mode: Mode, rel: string | null, isWindows: boolean): boolean {
51 if (named === null || named.turnId === null || mode === 'C' || rel === null) return false
52 const fold = (s: string) => (isWindows ? s.toLowerCase() : s)
53 return named.paths.some(p => fold(p) === fold(rel))
54}
55hooks/core/scope.ts 185 lines1// Where a written path falls against the scope, and what each mode does about
2// it (design note §2-§3). Pure: register.tsx resolves real paths and acts.
3
4import type { Pattern } from '../shared/glob.ts'
5import { relativeTo } from '../shared/paths.ts'
6
7export type Mode = 'A' | 'B' | 'C'
8
9export type Ctx = {
10 // The repository root, or the session's folder outside git: patterns are
11 // anchored here.
12 root: string
13 // ~/.claude (or CLAUDE_CONFIG_DIR): memory, plans, todos.
14 claudeDir: string
15 tempDirs: readonly string[]
16 isWindows: boolean
17}
18
19export type Where =
20 | { kind: 'in' }
21 // `bookkeeping`: under a screenshot, agent or cache folder (isBookkeepingPath).
22 | { kind: 'exempt'; why: 'claude' | 'temp' | 'lockfile' | 'bookkeeping' }
23 // `rel`: the path under the root, or null for a file outside the project.
24 | { kind: 'out'; rel: string | null }
25
26const LOCKFILES = new Set([
27 'package-lock.json',
28 'npm-shrinkwrap.json',
29 'pnpm-lock.yaml',
30 'yarn.lock',
31 'bun.lock',
32 'bun.lockb',
33 'deno.lock',
34 'cargo.lock',
35 'poetry.lock',
36 'pdm.lock',
37 'uv.lock',
38 'pipfile.lock',
39 'gemfile.lock',
40 'composer.lock',
41 'go.sum',
42 'packages.lock.json',
43 'podfile.lock',
44 'pubspec.lock',
45 'mix.lock',
46 'flake.lock',
47])
48
49export function isLockfile(path: string): boolean {
50 const normal = path.replace(/\\/g, '/')
51 return LOCKFILES.has(normal.slice(normal.lastIndexOf('/') + 1).toLowerCase())
52}
53
54// Claude's own files, then lockfiles, then scratch, then bookkeeping folders
55// (secrets aside), then the project; temp folders only outside the project,
56// so a project that lives in temp is still guarded.
57export function classify(path: string, ctx: Ctx, patterns: readonly Pattern[]): Where {
58 if (under(ctx.claudeDir, path, ctx.isWindows)) return { kind: 'exempt', why: 'claude' }
59 if (isLockfile(path)) return { kind: 'exempt', why: 'lockfile' }
60 const rel = relativeTo(ctx.root, path, ctx.isWindows)
61 // Inside the project only the part under the root counts, so a project
62 // that itself lives in a scratch or bookkeeping folder is still guarded.
63 if (isScratchPath(rel ?? path, ctx.isWindows)) return { kind: 'exempt', why: 'temp' }
64 if (isBookkeepingPath(rel ?? path, ctx.isWindows) && !isSecretPath(path)) return { kind: 'exempt', why: 'bookkeeping' }
65 if (rel !== undefined) return inScope(patterns, rel, ctx.isWindows) ? { kind: 'in' } : { kind: 'out', rel }
66 if (ctx.tempDirs.some(dir => under(dir, path, ctx.isWindows))) return { kind: 'exempt', why: 'temp' }
67 return { kind: 'out', rel: null }
68}
69
70// Scratch outside the system temp counts as temp: a folder named `scratch`,
71// `.scratch`, `*-scratch`, `tmp`, `.tmp`, `tmp-*` or `.tmp-*` (which covers
72// the roots `/tmp` and `C:/tmp`), or a file name containing `.tmp.`. Case
73// counts except on Windows.
74const SCRATCH_FOLDER = /^(?:\.?scratch|.+-scratch|\.?tmp|\.?tmp-.*)$/
75
76export function isScratchPath(path: string, isWindows: boolean): boolean {
77 const parts = path.replace(/\\/g, '/').split('/')
78 const name = parts.pop() ?? ''
79 const fold = (s: string) => (isWindows ? s.toLowerCase() : s)
80 return parts.some(part => SCRATCH_FOLDER.test(fold(part))) || fold(name).includes('.tmp.')
81}
82
83// Bookkeeping folders: screenshots, an agent's notes, caches. A path under a
84// folder named `shots`, `.shots`, `screenshots`, `.agent` or `.cache` is
85// exempt, inside the project or outside it. Case counts except on Windows.
86const BOOKKEEPING_FOLDERS = new Set(['shots', '.shots', 'screenshots', '.agent', '.cache'])
87
88export function isBookkeepingPath(path: string, isWindows: boolean): boolean {
89 const parts = path.replace(/\\/g, '/').split('/').slice(0, -1)
90 return parts.some(part => BOOKKEEPING_FOLDERS.has(isWindows ? part.toLowerCase() : part))
91}
92
93// Secret-looking files: flagged even in a bookkeeping folder, so a stray
94// secret write still shows. By name, any case.
95const SECRET_NAME = /^(?:\.env|\.env\..*|.*\.pem|.*\.key|.*\.p12|.*\.pfx|id_rsa.*|.*\.keystore|credentials.*|secrets\..*)$/i
96
97export function isSecretPath(path: string): boolean {
98 const normal = path.replace(/\\/g, '/')
99 return SECRET_NAME.test(normal.slice(normal.lastIndexOf('/') + 1))
100}
101
102// ---------------------------------------------------- the scope and its tests
103
104// A scope includes its tests, silently. A test file is one named `*.test.*`,
105// `*.spec.*`, `test_*.py`, `*_test.py` or `*_test.go`, or one under a folder
106// named `test`, `tests`, `__tests__`, `spec` or `e2e`. Its folder is compared
107// with each pattern's folder after both drop those test folders and a leading
108// `src/`: the test is in when its folder is under (or is) the pattern's, or
109// when the pattern is a plain folder (`src/**`) left empty by that. So with
110// `src/auth/**`, `test/auth/login.test.ts` and `tests/auth/x.ts` are in, and
111// `test/db/x.test.ts` and `e2e/checkout.spec.ts` are not. Only patterns whose
112// folder part is literal mirror (`src/auth/**`, `src/auth/*.ts`; not
113// `src/*/auth/**`). A pattern matches a test file directly or by mirror, and
114// the last pattern that matches decides, so `!src/auth/legacy/**` still keeps
115// `test/auth/legacy/x.test.ts` out.
116const TEST_FOLDERS = new Set(['test', 'tests', '__tests__', 'spec', 'e2e'])
117const TEST_NAME = /(?:\.test\.|\.spec\.|^test_.*\.py$|_test\.py$|_test\.go$)/
118
119export function isTestPath(rel: string, isWindows: boolean): boolean {
120 const parts = rel.replace(/\\/g, '/').split('/')
121 const name = parts.pop() ?? ''
122 const fold = (s: string) => (isWindows ? s.toLowerCase() : s)
123 return TEST_NAME.test(fold(name)) || parts.some(part => TEST_FOLDERS.has(fold(part)))
124}
125
126// `rel` is relative to the root the patterns are anchored at.
127export function inScope(patterns: readonly Pattern[], rel: string, isWindows: boolean): boolean {
128 const path = rel.replace(/\\/g, '/').replace(/^(\.\/|\/)+/, '')
129 const test = isTestPath(path, isWindows) ? mirrorFolder(path.split('/').slice(0, -1), isWindows) : null
130 let inside = false
131 for (const p of patterns) if (p.re.test(path) || (test !== null && mirrors(p, test, isWindows))) inside = !p.negate
132 return inside
133}
134
135function mirrors(pattern: Pattern, test: string[], isWindows: boolean): boolean {
136 const folder = patternFolder(pattern.source)
137 if (folder === null) return false
138 const scope = mirrorFolder(folder.parts, isWindows)
139 if (scope.length === 0) return folder.plain
140 return scope.length <= test.length && scope.every((part, i) => part === test[i])
141}
142
143// A pattern's literal folder: its segments before the first with a glob
144// character, all of them for a plain folder (`src/auth`, `src/auth/`,
145// `src/auth/**`). `plain`: the pattern names a folder and nothing more.
146function patternFolder(source: string): { parts: string[]; plain: boolean } | null {
147 let body = source.trim().replace(/\\/g, '/').replace(/^!/, '').replace(/^(\.\/|\/)+/, '')
148 if (body.endsWith('/**')) body = body.slice(0, -3)
149 const parts = body.split('/').filter(s => s !== '' && s !== '.')
150 const glob = parts.findIndex(s => /[*?[]/.test(s))
151 if (glob < 0) return { parts, plain: true }
152 if (glob === 0) return null
153 return { parts: parts.slice(0, glob), plain: false }
154}
155
156// A folder with its test folders dropped, then a leading `src`; folded on Windows.
157function mirrorFolder(parts: readonly string[], isWindows: boolean): string[] {
158 const fold = (s: string) => (isWindows ? s.toLowerCase() : s)
159 const kept = parts.map(fold).filter(s => s !== '' && !TEST_FOLDERS.has(s))
160 return kept[0] === 'src' ? kept.slice(1) : kept
161}
162
163// `file`: Edit, Write or NotebookEdit, before it runs. `shell`: a file a shell
164// command changed, seen after the run. `shell-before`: a literal write target
165// parsed from a command, before the run.
166export type Kind = 'file' | 'shell' | 'shell-before'
167
168// `tell`: when Claude hears about it. `now` rides on the call's result;
169// `next-prompt` on the next user prompt. A deny's reason is Claude's error.
170export type Action = { deny: boolean; flag: boolean; tell: 'now' | 'next-prompt' | null }
171
172const NOTHING: Action = { deny: false, flag: false, tell: null }
173
174export function decide(mode: Mode, kind: Kind, where: Where): Action {
175 if (where.kind !== 'out') return NOTHING
176 if (kind === 'shell-before') return mode === 'C' ? { deny: true, flag: true, tell: null } : NOTHING
177 if (kind === 'shell') return { deny: false, flag: true, tell: mode === 'A' ? null : 'next-prompt' }
178 if (mode === 'C') return { deny: true, flag: true, tell: null }
179 return { deny: false, flag: true, tell: mode === 'B' ? 'now' : null }
180}
181
182function under(dir: string, path: string, isWindows: boolean): boolean {
183 return dir !== '' && relativeTo(dir, path, isWindows) !== undefined
184}
185hooks/core/shell.ts 513 lines1// The files a Bash or PowerShell command will write, read from its text
2// before it runs (design note §4). Conservative: only literal targets of
3// commands we know count. A variable, glob, substitution, escape, an unknown
4// program or a relative path after a `cd` makes the command `unknown`, which
5// is never denied and never flagged. Pure.
6
7export type ShellWrites = { targets: string[]; unknown: boolean }
8
9// `quoted`: the word began with a quote (in PowerShell, a string value, not a command).
10type Token = { text: string; literal: boolean; quoted?: true; redirect?: 'out' | 'in' }
11// What one command writes: literal paths, some of them only (`partial`), a
12// change of directory, or nothing we can name.
13type Found = string[] | { partial: string[] } | 'cd' | 'unknown'
14type Segment = Token[]
15
16export function shellWrites(command: string, tool: 'Bash' | 'PowerShell'): ShellWrites {
17 const segments = tool === 'Bash' ? tokenizeBash(command) : tokenizePowerShell(command)
18 if (segments === null) return { targets: [], unknown: true }
19 const targets: string[] = []
20 let unknown = false
21 let moved = false
22 const add = (path: string) => {
23 if (moved && !isAbsolute(path)) unknown = true
24 else if (!targets.includes(path)) targets.push(path)
25 }
26 for (const segment of segments) {
27 for (const t of segment) {
28 if (t.redirect !== 'out') continue
29 if (!t.literal) unknown = true
30 else if (!isNullDevice(t.text)) add(t.text)
31 }
32 const words = segment.filter(t => t.redirect === undefined)
33 if (words.length === 0) continue
34 const r = tool === 'Bash' ? bashCommand(words) : powerShellCommand(words)
35 if (r === 'cd') moved = true
36 else if (r === 'unknown') unknown = true
37 else if (Array.isArray(r)) r.forEach(add)
38 else {
39 r.partial.forEach(add)
40 unknown = true
41 }
42 }
43 return { targets, unknown }
44}
45
46function isAbsolute(path: string): boolean {
47 return /^(?:[A-Za-z]:|[/\\])/.test(path)
48}
49
50function isNullDevice(path: string): boolean {
51 return /^(?:\/dev\/(?:null|stdout|stderr|tty)|nul|\$null)$/i.test(path)
52}
53
54// ---------------------------------------------------------------- Bash
55
56const BASH_READERS = new Set([
57 'cat', 'less', 'more', 'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'ls', 'wc', 'uniq', 'diff', 'cmp', 'echo',
58 'printf', 'pwd', 'which', 'type', 'test', '[', 'true', 'false', 'stat', 'file', 'du', 'df', 'date', 'sleep', 'jq',
59 'xxd', 'od', 'basename', 'dirname', 'realpath', 'readlink', 'tree', 'export', 'set', 'unset', 'exit', 'wait',
60 'ps', 'whoami', 'uname', 'hostname', 'env', 'printenv', 'column', 'cut', 'tr', 'nl', 'rev', 'comm', 'md5sum',
61 'sha256sum', 'shasum',
62])
63const GIT_READERS = new Set(['status', 'diff', 'log', 'show', 'rev-parse', 'ls-files', 'grep', 'blame', 'fetch', 'remote', 'config', 'describe', 'shortlog', 'cat-file', 'ls-tree', 'branch'])
64const WRAPPERS = new Set(['sudo', 'command', 'builtin', 'exec', 'time', 'nohup'])
65
66function bashCommand(words: Token[]): Found {
67 let i = 0
68 while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!.text) || WRAPPERS.has(words[i]!.text))) i++
69 if (i >= words.length) return []
70 if (!words[i]!.literal) return 'unknown'
71 const name = words[i]!.text.replace(/^.*\//, '').toLowerCase()
72 const args = words.slice(i + 1)
73 if (name === 'cd' || name === 'pushd' || name === 'popd') return 'cd'
74 if (name === 'git') {
75 const sub = args.find(a => !a.text.startsWith('-'))
76 return sub !== undefined && GIT_READERS.has(sub.text) ? [] : 'unknown'
77 }
78 if (name === 'sed') return sedTargets(args)
79 if (name === 'awk') return args.some(a => a.text === '-i' || a.text.startsWith('-i')) ? 'unknown' : []
80 if (name === 'sort') {
81 const at = args.findIndex(a => a.text === '-o')
82 return at < 0 ? [] : literals(args.slice(at + 1, at + 2))
83 }
84 if (name === 'find') return args.some(a => /^-(?:delete|exec|execdir|ok|fprint)/.test(a.text)) ? 'unknown' : []
85 if (BASH_READERS.has(name)) return []
86 const operands = args.filter(a => !a.text.startsWith('-') || !a.literal)
87 switch (name) {
88 case 'tee':
89 case 'rm':
90 case 'rmdir':
91 case 'touch':
92 case 'mkdir':
93 case 'truncate':
94 return literals(name === 'truncate' ? withoutValueOf(args, ['-s', '--size']) : operands)
95 case 'cp':
96 case 'mv': {
97 const t = args.findIndex(a => a.text === '-t' || a.text === '--target-directory')
98 if (t >= 0) {
99 const dest = literals(args.slice(t + 1, t + 2))
100 if (dest === 'unknown' || name === 'cp') return dest
101 const sources = literals(operands.filter(a => a !== args[t + 1]))
102 return sources === 'unknown' ? 'unknown' : [...dest, ...sources]
103 }
104 if (operands.length < 2) return 'unknown'
105 return literals(name === 'cp' ? operands.slice(-1) : operands)
106 }
107 default:
108 return 'unknown'
109 }
110}
111
112function sedTargets(args: Token[]): string[] | 'unknown' {
113 const inPlace = args.some(a => /^(?:-i|--in-place)/.test(a.text) || /^-[a-hj-z]*i/.test(a.text))
114 if (!inPlace) return []
115 let script = !args.some(a => a.text === '-e' || a.text === '-f' || a.text === '--expression' || a.text === '--file')
116 const files: Token[] = []
117 for (let i = 0; i < args.length; i++) {
118 const a = args[i]!
119 if (a.text === '-e' || a.text === '-f' || a.text === '--expression' || a.text === '--file') {
120 i++
121 continue
122 }
123 if (a.literal && a.text.startsWith('-')) continue
124 if (script) {
125 script = false
126 continue
127 }
128 files.push(a)
129 }
130 return files.length === 0 ? 'unknown' : literals(files)
131}
132
133function withoutValueOf(args: Token[], options: string[]): Token[] {
134 const out: Token[] = []
135 for (let i = 0; i < args.length; i++) {
136 if (options.includes(args[i]!.text)) i++
137 else if (!args[i]!.text.startsWith('-')) out.push(args[i]!)
138 }
139 return out
140}
141
142function literals(tokens: Token[]): string[] | 'unknown' {
143 if (tokens.length === 0) return []
144 return tokens.every(t => t.literal) ? tokens.map(t => t.text) : 'unknown'
145}
146
147// Words per segment, split on ; && || | & newlines and ( ) { }, quotes kept
148// whole. A word is literal unless it holds an expansion. Heredoc bodies and
149// comments are skipped. Null for an unclosed quote.
150function tokenizeBash(command: string): Segment[] | null {
151 const segments: Segment[] = []
152 let segment: Segment = []
153 let text = ''
154 let literal = true
155 let inWord = false
156 let pending: Token['redirect'] | 'skip' | null = null
157 const heredocs: string[] = []
158 const endWord = () => {
159 if (!inWord) return
160 if (pending === 'skip') pending = null
161 else {
162 segment.push(pending === null ? { text, literal } : { text, literal, redirect: pending })
163 pending = null
164 }
165 text = ''
166 literal = true
167 inWord = false
168 }
169 const endSegment = () => {
170 endWord()
171 if (segment.length > 0) segments.push(segment)
172 segment = []
173 }
174 let i = 0
175 const n = command.length
176 while (i < n) {
177 const c = command[i]
178 if (c === "'" || c === '"') {
179 const close = command.indexOf(c, i + 1)
180 if (close < 0) return null
181 const inner = command.slice(i + 1, close)
182 if (c === '"' && /[$`\\]/.test(inner)) literal = false
183 text += inner
184 inWord = true
185 i = close + 1
186 continue
187 }
188 if (c === '\\') {
189 if (command[i + 1] === '\n') {
190 i += 2
191 continue
192 }
193 literal = false
194 text += command.slice(i, i + 2)
195 inWord = true
196 i += 2
197 continue
198 }
199 if (c === '$' || c === '`') {
200 literal = false
201 inWord = true
202 if (command.startsWith('$(', i) || c === '`') {
203 const end = c === '`' ? command.indexOf('`', i + 1) : closingParen(command, i + 1)
204 if (end < 0) return null
205 text += command.slice(i, end + 1)
206 i = end + 1
207 continue
208 }
209 text += c
210 i++
211 continue
212 }
213 if (c === '#' && !inWord) {
214 while (i < n && command[i] !== '\n') i++
215 continue
216 }
217 if (c === '\n') {
218 endSegment()
219 i++
220 for (const delimiter of heredocs.splice(0)) {
221 while (i < n) {
222 const eol = command.indexOf('\n', i)
223 const line = command.slice(i, eol < 0 ? n : eol)
224 i = eol < 0 ? n : eol + 1
225 if (line.replace(/^\t+/, '').trim() === delimiter) break
226 }
227 }
228 continue
229 }
230 if (c === ' ' || c === '\t') {
231 endWord()
232 i++
233 continue
234 }
235 if (c === '<') {
236 endWord()
237 if (command.startsWith('<<<', i)) {
238 pending = 'skip'
239 i += 3
240 continue
241 }
242 if (command.startsWith('<<', i)) {
243 i += 2
244 if (command[i] === '-') i++
245 while (command[i] === ' ' || command[i] === '\t') i++
246 const m = /^(['"]?)([A-Za-z0-9_.-]+)\1/.exec(command.slice(i))
247 if (m === null) return null
248 heredocs.push(m[2]!)
249 i += m[0].length
250 continue
251 }
252 pending = 'skip'
253 i++
254 continue
255 }
256 if (c === '>' || (c === '&' && command[i + 1] === '>')) {
257 if (inWord && /^\d+$/.test(text) && literal) {
258 text = ''
259 inWord = false
260 } else endWord()
261 i += c === '&' ? 2 : 1
262 if (command[i] === '>' || command[i] === '|') i++
263 if (command[i] === '&') {
264 i++
265 while (/[\d-]/.test(command[i] ?? '')) i++
266 continue
267 }
268 pending = 'out'
269 continue
270 }
271 if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || ((c === '{' || c === '}') && !inWord)) {
272 endSegment()
273 i += command.startsWith('&&', i) || command.startsWith('||', i) || command.startsWith('|&', i) || command.startsWith(';;', i) ? 2 : 1
274 continue
275 }
276 if (c === '*' || c === '?' || c === '[' || c === '{' || (c === '~' && !inWord)) literal = false
277 text += c
278 inWord = true
279 i++
280 }
281 endSegment()
282 return segments
283}
284
285function closingParen(command: string, open: number): number {
286 let depth = 0
287 for (let i = open; i < command.length; i++) {
288 const c = command[i]
289 if (c === "'" || c === '"') {
290 const close = command.indexOf(c, i + 1)
291 if (close < 0) return -1
292 i = close
293 } else if (c === '(') depth++
294 else if (c === ')' && --depth === 0) return i
295 }
296 return -1
297}
298
299// ---------------------------------------------------------- PowerShell
300
301const PS_READERS = new Set([
302 'get-content', 'gc', 'cat', 'type', 'get-childitem', 'gci', 'ls', 'dir', 'select-string', 'sls', 'write-output',
303 'echo', 'write', 'write-host', 'test-path', 'get-item', 'gi', 'resolve-path', 'measure-object', 'select-object',
304 'where-object', 'sort-object', 'format-table', 'format-list', 'out-host', 'out-null', 'out-string', 'get-date',
305 'start-sleep', 'get-location', 'pwd', 'gl', 'get-command', 'gcm', 'join-path', 'split-path', 'convertto-json',
306 'convertfrom-json', 'get-process', 'get-filehash', 'compare-object', 'get-itemproperty',
307])
308const PS_CD = new Set(['cd', 'chdir', 'set-location', 'sl', 'push-location', 'pushd', 'pop-location', 'popd'])
309
310// Each writer: its canonical name, and which positional parameters name the
311// paths it writes (`dest` written, `source` written too for a move).
312type Writer = { positions: readonly string[]; writes: readonly string[] }
313const CONTENT: Writer = { positions: ['path', 'value'], writes: ['path', 'literalpath'] }
314const PS_WRITERS: Record<string, Writer> = {
315 'set-content': CONTENT,
316 sc: CONTENT,
317 'add-content': CONTENT,
318 ac: CONTENT,
319 'clear-content': CONTENT,
320 clc: CONTENT,
321 'out-file': { positions: ['filepath'], writes: ['filepath', 'literalpath'] },
322 'tee-object': { positions: ['filepath'], writes: ['filepath', 'literalpath'] },
323 tee: { positions: ['filepath'], writes: ['filepath', 'literalpath'] },
324 'new-item': { positions: ['path'], writes: ['path'] },
325 ni: { positions: ['path'], writes: ['path'] },
326 mkdir: { positions: ['path'], writes: ['path'] },
327 md: { positions: ['path'], writes: ['path'] },
328 'remove-item': { positions: ['path'], writes: ['path', 'literalpath'] },
329 ri: { positions: ['path'], writes: ['path', 'literalpath'] },
330 rm: { positions: ['path'], writes: ['path', 'literalpath'] },
331 del: { positions: ['path'], writes: ['path', 'literalpath'] },
332 erase: { positions: ['path'], writes: ['path', 'literalpath'] },
333 rd: { positions: ['path'], writes: ['path', 'literalpath'] },
334 rmdir: { positions: ['path'], writes: ['path', 'literalpath'] },
335 'copy-item': { positions: ['path', 'destination'], writes: ['destination'] },
336 copy: { positions: ['path', 'destination'], writes: ['destination'] },
337 cp: { positions: ['path', 'destination'], writes: ['destination'] },
338 cpi: { positions: ['path', 'destination'], writes: ['destination'] },
339 'move-item': { positions: ['path', 'destination'], writes: ['path', 'literalpath', 'destination'] },
340 move: { positions: ['path', 'destination'], writes: ['path', 'literalpath', 'destination'] },
341 mv: { positions: ['path', 'destination'], writes: ['path', 'literalpath', 'destination'] },
342 mi: { positions: ['path', 'destination'], writes: ['path', 'literalpath', 'destination'] },
343 'rename-item': { positions: ['path', 'newname'], writes: ['path', 'literalpath'] },
344 ren: { positions: ['path', 'newname'], writes: ['path', 'literalpath'] },
345 rni: { positions: ['path', 'newname'], writes: ['path', 'literalpath'] },
346}
347const PS_SWITCHES = new Set(['force', 'recurse', 'nonewline', 'append', 'passthru', 'confirm', 'noclobber', 'asbytestream', 'raw', 'container'])
348const PS_VALUES = new Set(['path', 'literalpath', 'filepath', 'destination', 'value', 'encoding', 'itemtype', 'name', 'filter', 'include', 'exclude', 'width', 'inputobject', 'stream', 'newname'])
349
350function powerShellCommand(words: Token[]): Found {
351 const first = words[0]!
352 const args = words.slice(1)
353 if (first.quoted) return []
354 if (!first.literal) return 'unknown'
355 const name = first.text.toLowerCase()
356 if (PS_CD.has(name)) return 'cd'
357 if (PS_READERS.has(name)) return []
358 const writer = PS_WRITERS[name]
359 if (writer === undefined) return 'unknown'
360 const named = new Map<string, Token>()
361 const positional: Token[] = []
362 for (let i = 0; i < args.length; i++) {
363 const a = args[i]!
364 const m = a.literal ? /^-([A-Za-z]+)(?::(.*))?$/.exec(a.text) : null
365 if (m === null) {
366 positional.push(a)
367 continue
368 }
369 const param = m[1]!.toLowerCase()
370 if (param === 'whatif') return []
371 if (PS_SWITCHES.has(param)) continue
372 if (!PS_VALUES.has(param)) return 'unknown'
373 if (m[2] !== undefined) named.set(param, { text: m[2], literal: true })
374 else if (i + 1 < args.length) named.set(param, args[++i]!)
375 else return 'unknown'
376 }
377 if (name === 'new-item' || name === 'ni') {
378 if (named.has('name')) return 'unknown'
379 }
380 const free = [...writer.positions].filter(p => !named.has(p) && !(p === 'path' && named.has('literalpath')) && !(p === 'filepath' && named.has('literalpath')))
381 positional.forEach((t, k) => {
382 if (k < free.length) named.set(free[k]!, t)
383 })
384 if (positional.length > free.length) return 'unknown'
385 const out: Token[] = writer.writes.map(p => named.get(p)).filter((t): t is Token => t !== undefined)
386 if (out.length === 0) return 'unknown'
387 if (out.some(t => t.text.includes(','))) return 'unknown'
388 const paths = literals(out)
389 if (paths === 'unknown') return paths
390 // A rename's new name sits beside the old one: written, but not named here.
391 return name === 'rename-item' || name === 'ren' || name === 'rni' ? { partial: paths } : paths
392}
393
394function tokenizePowerShell(command: string): Segment[] | null {
395 const segments: Segment[] = []
396 let segment: Segment = []
397 let text = ''
398 let literal = true
399 let inWord = false
400 let quoted = false
401 let pending: Token['redirect'] | null = null
402 const endWord = () => {
403 if (!inWord) return
404 segment.push({ text, literal, ...(quoted ? { quoted: true as const } : {}), ...(pending === null ? {} : { redirect: pending }) })
405 pending = null
406 text = ''
407 literal = true
408 quoted = false
409 inWord = false
410 }
411 const endSegment = () => {
412 endWord()
413 if (segment.length > 0) segments.push(segment)
414 segment = []
415 }
416 let i = 0
417 const n = command.length
418 while (i < n) {
419 const c = command[i]!
420 if ((command.startsWith("@'", i) || command.startsWith('@"', i)) && command[i + 2] === '\n') {
421 const close = command.indexOf(`\n${command[i + 1]}@`, i + 2)
422 if (close < 0) return null
423 if (command[i + 1] === '"') literal = false
424 if (!inWord) quoted = true
425 text += command.slice(i + 3, close)
426 inWord = true
427 i = close + 3
428 continue
429 }
430 if (c === "'") {
431 let j = i + 1
432 let inner = ''
433 for (;;) {
434 const close = command.indexOf("'", j)
435 if (close < 0) return null
436 inner += command.slice(j, close)
437 if (command[close + 1] === "'") {
438 inner += "'"
439 j = close + 2
440 continue
441 }
442 i = close + 1
443 break
444 }
445 if (!inWord) quoted = true
446 text += inner
447 inWord = true
448 continue
449 }
450 if (c === '"') {
451 const close = command.indexOf('"', i + 1)
452 if (close < 0) return null
453 const inner = command.slice(i + 1, close)
454 if (/[$`]/.test(inner)) literal = false
455 if (!inWord) quoted = true
456 text += inner
457 inWord = true
458 i = close + 1
459 continue
460 }
461 if (c === '`' || c === '$') {
462 literal = false
463 if (command.startsWith('$null', i) && !/[\w]/.test(command[i + 5] ?? '')) literal = true
464 text += c
465 inWord = true
466 i++
467 continue
468 }
469 if (command.startsWith('<#', i)) {
470 const close = command.indexOf('#>', i + 2)
471 i = close < 0 ? n : close + 2
472 continue
473 }
474 if (c === '#' && !inWord) {
475 while (i < n && command[i] !== '\n') i++
476 continue
477 }
478 if (c === ' ' || c === '\t' || c === '\r') {
479 endWord()
480 i++
481 continue
482 }
483 if (c === '>' || ((c === '*' || /\d/.test(c)) && command[i + 1] === '>' && !inWord)) {
484 endWord()
485 i += c === '>' ? 1 : 2
486 if (command[i] === '>') i++
487 if (command[i] === '&') {
488 i++
489 while (/\d/.test(command[i] ?? '')) i++
490 continue
491 }
492 pending = 'out'
493 continue
494 }
495 if (c === '\n' || c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || c === '{' || c === '}') {
496 endSegment()
497 const double = command.startsWith('&&', i) || command.startsWith('||', i)
498 // `& prog`: the call operator runs something we can't name.
499 if (c === '&' && !double) segments.push([{ text: '&', literal: false }])
500 i += double ? 2 : 1
501 continue
502 }
503 if (c === '[' && !inWord) literal = false
504 if (c === '*' || c === '?' || c === '[') literal = false
505 if (c === '@' && !inWord) literal = false
506 text += c
507 inWord = true
508 i++
509 }
510 endSegment()
511 return segments
512}
513hooks/ui/band.tsx 32 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import { bandText, type FlagView } from '../core/copy.ts'
4
5type BandElements = Pick<Elements[RenderSurface], 'Box' | 'Button' | 'Text'>
6
7export type BandHandlers = {
8 // Adds the newest outside file to the scope; null when it has no path
9 // under the project (a pattern can't name it).
10 onAllow: (() => void) | null
11 // Null while the pane is open.
12 onDetails: (() => void) | null
13 onDismiss: () => void
14 onClear: () => void
15}
16
17// One row while a scope is set (design note §5): the scope, and what went
18// outside it since the last prompt. Pure: register.tsx reads the state.
19export function bandView({ Box, Button, Text }: BandElements, patterns: readonly string[], outside: readonly FlagView[], on: BandHandlers) {
20 const quiet = outside.length === 0
21 return (
22 <Box key="scope-cop-band" flexDirection="row" gap={1}>
23 <Text color={quiet ? undefined : 'warning'} dimColor={quiet} wrap="truncate-end">
24 {bandText(patterns, outside)}
25 </Text>
26 {!quiet && on.onAllow !== null ? <Button key="allow" label="Allow file" onPress={on.onAllow} /> : null}
27 {!quiet && on.onDetails !== null ? <Button key="details" label="Details" onPress={on.onDetails} /> : null}
28 {quiet ? <Button key="clear" label="Clear" onPress={on.onClear} /> : <Button key="dismiss" label="Dismiss" onPress={on.onDismiss} />}
29 </Box>
30 )
31}
32hooks/ui/pane.tsx 52 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import { MODE_TEXT, NO_SCOPE_TEXT, OUTCOME_TEXT } from '../core/copy.ts'
4import type { Mode } from '../core/scope.ts'
5
6type PaneElements = Pick<Elements[RenderSurface], 'Box' | 'Button' | 'Text'>
7
8export type PaneFlag = { at: number; path: string; tool: string; via: 'tool' | 'shell' | 'sweep'; agentId?: string; outcome: 'flagged' | 'told' | 'denied' | 'approved' }
9
10const GLYPH = { flagged: '⚠', told: '⚠', denied: '⛔', approved: '✓' } as const
11
12// The /scope pane: the patterns with [Remove], the mode, and every edit
13// outside the scope this conversation, newest first. Pure.
14export function paneView({ Box, Button, Text }: PaneElements, patterns: readonly string[] | null, mode: Mode, log: readonly PaneFlag[], now: number, onRemove: (pattern: string) => void) {
15 return (
16 <Box key="scope-cop" flexDirection="column">
17 {patterns === null ? (
18 <Text dimColor>{NO_SCOPE_TEXT}</Text>
19 ) : (
20 <Box key="patterns" flexDirection="column" marginBottom={1}>
21 <Text bold>Scope</Text>
22 {patterns.map(p => (
23 <Box key={`pattern-${p}`} flexDirection="row" gap={1}>
24 <Text>{p}</Text>
25 <Button key={`remove-${p}`} label="Remove" onPress={() => onRemove(p)} />
26 </Box>
27 ))}
28 <Text dimColor>Mode {MODE_TEXT[mode]}.</Text>
29 </Box>
30 )}
31 {log.length === 0 ? (
32 patterns === null ? null : <Text dimColor>Nothing outside the scope yet.</Text>
33 ) : (
34 [...log].reverse().map((f, i) => (
35 <Text key={`flag-${f.at}-${i}`} color={f.outcome === 'denied' ? 'error' : f.outcome === 'approved' ? undefined : 'warning'} wrap="truncate-end">
36 {GLYPH[f.outcome]} {f.path} · {f.via === 'shell' ? 'shell command' : f.via === 'sweep' ? 'changed during the turn (shell or you)' : f.tool}
37 {f.agentId !== undefined ? ' (subagent)' : ''} · {OUTCOME_TEXT[f.outcome]} · {ago(now - f.at)}
38 </Text>
39 ))
40 )}
41 </Box>
42 )
43}
44
45function ago(ms: number): string {
46 const minutes = Math.floor(Math.max(0, ms) / 60_000)
47 if (minutes < 1) return 'just now'
48 if (minutes < 60) return `${minutes}m ago`
49 const hours = Math.floor(minutes / 60)
50 return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
51}
52hooks/shared/paths.ts 18 lines1// GENERATED by scripts/sync-shared.mjs from /shared. Do not edit here.
2// Paths as git, the panes and scopes want them. Pure: forward slashes, and on
3// Windows a case-insensitive prefix test, since its file system ignores case.
4
5export function relativeTo(root: string, path: string, isWindows: boolean): string | undefined {
6 const base = root.replace(/\\/g, '/').replace(/\/+$/, '')
7 const full = path.replace(/\\/g, '/')
8 const fold = (s: string) => (isWindows ? s.toLowerCase() : s)
9 if (!fold(full).startsWith(fold(base) + '/')) return undefined
10 const rest = full.slice(base.length + 1)
11 return rest === '' ? undefined : rest
12}
13
14// For display: relative to the session's folder when under it, else as given.
15export function shownPath(cwd: string, path: string, isWindows: boolean): string {
16 return relativeTo(cwd, path, isWindows) ?? path.replace(/\\/g, '/')
17}
18