SLOPSHOPPER

scope-cop

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

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-25candygoogy/notary/plugins/scope-cop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scope-cop
│ ┃ Scope ✕ › fix the failing auth test and add an audit log call │ ┃ No scope set. /scope src/auth/** limits │ ┃ Claude to that folder for this conversation. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /scope │ ⎿ scope-cop: Scope pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Scope
No scope set. /scope src/auth/** limits Claude to that folder for this conversation.
README

Scope Cop

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.

Set a scope

/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.

CommandWhat 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 offClears it.
/scope mode A, B or CSets the mode (below).
/scopeOpens 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.
  • A folder name covers everything under it: src/auth is the same as src/auth/**.
  • !pattern excludes: src/ !src/generated/. The last pattern that matches a file decides.
  • On Windows, case doesn't matter and \ works as well as /.
  • Absolute paths and .. 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.

Modes

ModeEdit, Write, NotebookEdit outside the scopeA shell command that wrote outside the scope
A: flagRuns. 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: blockRefused 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.

The band and the pane

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.

Shell commands

A mod sees a shell command's text before it runs, and nothing about what the process wrote. So:

  • With Changes installed, inside a git repository, Scope Cop reads the files each shell command changed from Changes' record, at the end of the turn, and flags the ones outside the scope. Changes can revert them.
  • Before the run, in mode C only, the literal write targets of commands it knows (>, 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.
  • A change during the turn that no call explains (Changes' changed during this turn (shell or you)) is listed in the pane only. It may be your own edit, so it's never on the band or told to Claude.
  • Anything it can't read for certain is never refused and never flagged: variables, globs, $(...), scripts and interpreters (python, node, npm run), and relative paths after a cd.

Settings

SettingCommandDefault
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/configon

Limits

  • It costs a round trip when the task needs a file outside the scope. Claude stops at the edge and names the file; you say yes. In our eval (16 focused tasks, Sonnet), 9 of 32 scoped runs stopped this way; one yes was enough in all but one.
  • Not a security boundary. Scripts, interpreters and network tools can write anywhere, and outside git (or without Changes) shell writes aren't seen at all.
  • Shell writes are seen after the fact. Only literal absolute targets, in mode C, are stopped before they run.
  • One scope per conversation, not per turn, and not kept across /resume.
  • File-writing MCP tools aren't checked.
  • Claude Code's own deny rules ("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).
Source 13 files
hooks/register.tsx 579 lines
1import 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}
579
hooks/shared/changes-index.ts 65 lines
1// 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}
65
hooks/shared/glob.ts 80 lines
1// 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}
80
hooks/shared/pane.ts 14 lines
1// 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}
14
hooks/core/commands.ts 74 lines
1// 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}`
74
hooks/core/copy.ts 85 lines
1// 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.'
85
hooks/core/named.ts 55 lines
1// 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}
55
hooks/core/scope.ts 185 lines
1// 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}
185
hooks/core/shell.ts 513 lines
1// 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}
513
hooks/ui/band.tsx 32 lines
1import 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}
32
hooks/ui/pane.tsx 52 lines
1import 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}
52
hooks/shared/paths.ts 18 lines
1// 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