SLOPSHOPPER

scope-guard

Counts the distinct files a turn edits and stops at a threshold to make the goal get restated, so a small ask cannot quietly become a refactor.

newguardcommandstatuspromptmodel
★ 1v0.3.0MITupdated 2026-09-21yash-gadodia/claude-mods/scope-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scope-guard
› fix the failing auth test and add an audit log call ⏺ 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-guard: scope-guard stops at 4 files; judge on ⎿ scope-guard: goal: fix the failing auth test ⎿ scope-guard: this turn has edited nothing yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods that keep an agent honest — plus a few that make the terminal fun.

claude-code · mod · function-hooks · typescript · macos

License: MIT Claude Code — mod test

usage  max  volty  opus-5  5h 34%  7d 12%  ctx 41% 82k  $1.23
scope: 3/4 files
 ▸

<sub>Thirteen mods, each drawing or guarding its own slice of the session. Above: usage-band and scope-guard.</sub>

usage-band, wod-band and wod-timer above the prompt <sub>usage-band, wod-band and wod-timer in a live session.</sub>

Why

Claude Code will tell you a deploy worked because git push exited 0. It will turn a one-line fix into a nine-file refactor and never mention it. Written rules in CLAUDE.md help until the model forgets them, and you find out on the deploy that breaks.

These are the same rules, moved out of prose and into the engine — where they hold whether or not the model remembers.

What a mod is

A mod is a Claude Code plugin whose behaviour lives in a TypeScript hooks module — register(on, options) wiring handlers onto engine events (tool.call, ui.render, turn.complete) rather than markdown the model reads. A mod can deny a tool call, rewrite it in flight, draw above the prompt, or put evidence in front of the model that it cannot argue with.

Every mod here is source you can read in one sitting. None of them phone home: there is no $.http.fetch anywhere in this repo.

Install

Function hooks are behind a flag. Set it first, in your shell profile or settings.json env:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Then, in Claude Code:

/plugin marketplace add yash-gadodia/claude-mods
/plugin install scope-guard@claude-mods

Install only what you want — each mod is independent. Update with claude plugin update <name>@claude-mods.

The mods

Discipline

ModWhat it does
scope-guardCounts the distinct files one turn edits. At the threshold it stops and makes the goal get restated, so a small ask cannot quietly become a refactor. /scope sets it.
deploy-verifyAfter a deploy command succeeds, waits for the GitHub Actions run it started, then curls the live URL with cache-busting and puts the verdict in the model's context. A deploy cannot be claimed without evidence.
receiptThe turn footer becomes a receipt: edits, runs and curls, with a warning when edits ran nothing. Destructive commands are never folded into a tool group, a claim of "fixed" with no run puts "unverified claim pending" in the spinner, and Tab suggests running the tests.
diff-reviewA docked pane with each edited file's hunk and keep or revert buttons. Reverting runs git directly; no model turn.
merge-gateDenies gh pr merge, a git merge on trunk, or a push to main unless the latest human message contains the word merge. Ship, push and deploy do not count. /merge-gate toggles it.
mini-offloadRewrites heavy Bash commands (test suites, builds, Docker) to run on a second machine over ssh — syncing the commit there first, because the remote checkout is the real hazard. /mini sets always, ask, or off.

Instruments

ModWhat it does
usage-bandThe 5-hour and 7-day limit windows, this session's context fill and cost, above the prompt. Nudges you to /clear when the window gets expensive.
money-bandLiquid assets, CPF, debt and month-to-date spend, read from a pair of SQLite databases over ssh. Every figure is the database's own; nothing is estimated.
copy-bandClick-to-copy buttons above the prompt for every code block and quoted draft in the last answer, plus a durable stash of older ones. Copying runs pbcopy directly — no model turn.
done-blinkWhen a turn lands, the iTerm2 tab blinks orange every half second until you send the next prompt or three minutes pass, so a finished session is obvious from any other tab. Works inside tmux with no passthrough config: the escape goes to the tmux client's tty. /done-blink 60 sets the ceiling.
chrome-switchSwitches the Claude in Chrome extension between named browser profiles using select_browser, which needs no approval click. /chromep maps them.

Fun

ModWhat it does
wod-bandA pixel-art athlete above the prompt who does a rep every turn. The session is an AMRAP of thrusters, burpees and pull-ups.
wod-timer3, 2, 1, GO in the spinner when you submit, a running gym clock while Claude works, and a whiteboard split in the footer when the turn lands: turn, time, AMRAP total, PR. /wod-timer voice on reads long splits aloud.

Turning them off

Every mod checks one environment variable before doing anything:

CLAUDE_MODS_DISABLE=all            # every mod in this repo becomes a pass-through
CLAUDE_MODS_DISABLE=scope-guard    # just that one
CLAUDE_MODS_DISABLE=wod-band,wod-timer

A disabled mod registers no command and every hook falls straight through to next(e).

Configuration

Mods that touch your machine declare their settings in plugin.json userConfig, so they are editable through /config rather than by hand:

  • scope-guard — /scope <n> sets the file threshold. /scope judge on|off (default on) lets a one-shot Haiku call decide at the threshold whether the next edit is still inside the goal you stated first; a yes raises the ceiling by one for that turn, a no or a failed call falls back to asking. /scope off disables the guard.
  • merge-gate — /merge-gate on|off. "merge x3" or "merge after each" in your message grants that many merges.
  • mini-offload — host (ssh alias, default mini), remotePath (the PATH export prefixed to every offloaded command). Per-repo overrides live at <repo>/.claude/mini-offload.json.
  • money-band — host, networthDb, financeDb. Expects SQLite databases with accounts/balances and transactions tables. efAccount (default UOB One) and efTarget (default 30000) feed the EF 41% footer label.
  • usage-band — sgdRate (default 1.30) for the S$ footer label; /usage-band sgd off hides it.
  • receipt — /receipt on|off|status.
  • done-blink — /done-blink on|off|status|<seconds> (default 180, max 900).
  • diff-review — /diff-review open|close|on|off.
  • deploy-verify — per-repo, at <repo>/.claude/deploy-verify.json: ``json { "url": "https://example.com", "matchFile": "VERSION" } ``
  • chrome-switch — ~/.claude/chrome-browsers.json, mapping labels to deviceIds.

Evidence, not decoration

scope-guard and deploy-verify also write a block into the model's own context (prompt.context), replacing their previous copy rather than accumulating:

# deployVerify
Last live deploy check, 2 minutes ago:
  VERIFIED live: https://example.com served "v3.10.10"
This is the only evidence about the live site in this session. Do not describe the deploy as
verified unless a line above starts with VERIFIED, and do not re-state an older claim over it.

A band above the prompt is for you. A context block is for the model — and it cannot be talked around. Repeated advisories are hashed and suppressed for a cooldown so this costs context once, not once per tool call; verdicts themselves are never throttled, because a verdict is evidence.

Tests

npm install
npm test

npm test typechecks every mod, runs its suite under claude plugin test (the official kit, claude-code/testing, with a mocked clock, store and process table), and checks each mod's footprint: the hooks, $ calls and env reads that claude plugin validate reports, pinned in <mod>/FOOTPRINT. A mod that starts calling $.http.fetch fails the build instead of a README sentence going stale. scripts/footprint.sh --write re-pins after a deliberate change.

The interesting half of deploy-verify's suite is the clean baseline: commands that mention a deploy without being one — echo "git push", grep -r "wrangler deploy", git push --dry-run, a commit message quoting make deploy, a heredoc containing one. A false positive curls a live URL nothing was pushed to and then reports a verdict about it, which is worse than not checking at all.

Design rules

The ones that survived contact with real sessions:

  1. A deny always carries the fix. Blocking without saying what to do instead strands the model in a retry loop. Every refusal here names the next action.
  2. Never block when there is no way through. If the only outcomes are "denied" and "denied again", let it run and say something instead.
  3. A render hook that throws takes the whole mod down with it. Every band wraps its frame in try/catch and falls back to what was there.
  4. Hooks have ten seconds of their own time. next(e) and $ calls are free; $.clock.sleep is not. Past the budget, or on a throw, the engine skips the hook silently unless it declares .catch — so every guard here catches and denies, and slow work belongs on a timer.
  5. Bands yield. e.props.hasSurvey means the engine wants that slot; give it back.

Requirements

Claude Code 2.1.271+ with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. macOS — copy-band shells out to pbcopy, and mini-offload/money-band assume ssh and a Homebrew path on the remote.

License

MIT

Source 1 files
hooks/register.ts 325 lines
1import type { Register, EngineInterface, SessionMessage, ToolCheckInput, ToolCheckResult } from 'claude-code'
2
3// Silent scope drift is the expensive failure: the one-line fix that touched nine files.
4// This mod counts the distinct files a single turn writes to, and at the threshold it stops
5// and asks for the goal to be restated. Answering "Continue" raises the bar for that turn only.
6
7const THRESHOLD_KEY = 'scope-guard:threshold'
8const JUDGE_KEY = 'scope-guard:judge'
9const DEFAULT_THRESHOLD = 4
10// tool.check's matcher takes an any-of list, so the scanner records these three, not every tool.
11const EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit']
12const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
13
14let threshold = DEFAULT_THRESHOLD
15let interactive = false
16let touched = new Set<string>()
17let ceiling = DEFAULT_THRESHOLD
18let paused = false
19let goal = ''
20let turnId: string | undefined
21let aborting: string | undefined
22
23const pathOf = (tool: string, input: unknown): string | undefined => {
24  const i = (input ?? {}) as Record<string, unknown>
25  const v = tool === 'NotebookEdit' ? i.notebook_path : i.file_path
26  return typeof v === 'string' ? v : undefined
27}
28
29const editOf = (input: unknown): string => {
30  const i = (input ?? {}) as Record<string, unknown>
31  const v = i.new_string ?? i.content ?? i.new_source
32  return typeof v === 'string' ? v.slice(0, 1500) : ''
33}
34
35const short = (path: string, root: string) => (path.startsWith(root) ? path.slice(root.length + 1) : path)
36
37// In bypass mode the harness edits through Bash, so a Bash call is read for the files it writes:
38// sed -i, a redirection, tee, cp/mv's destination, touch. Heredoc bodies and quoted strings are
39// blanked first (a quoted string becomes one opaque token) so `echo "> file"` names nothing.
40const QUOTED = '\u0001'
41const SCRATCH = ['/tmp/', '/private/tmp/', '/dev/']
42const FLAG = (t: string) => t.startsWith('-') && t !== '-'
43const WRITERS: Record<string, (args: string[]) => string[]> = {
44  sed: args => {
45    if (!args.some(a => a === '-i' || a.startsWith('-i') || a.startsWith('--in-place'))) return []
46    const rest = args.filter(a => !FLAG(a))
47    const scripted = args.some(a => a === '-e' || a === '--expression' || a.startsWith('--expression=')) || rest.includes(QUOTED)
48    return (scripted ? rest : rest.slice(1)).filter(a => a !== QUOTED)
49  },
50  tee: args => args.filter(a => !FLAG(a)),
51  touch: args => args.filter(a => !FLAG(a)),
52  cp: args => args.filter(a => !FLAG(a)).slice(-1),
53  mv: args => args.filter(a => !FLAG(a)).slice(-1),
54}
55
56export const bashTargets = (command: string, root: string): string[] => {
57  let text = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2(?=\n|$)/g, m => m.slice(0, m.indexOf('\n')))
58  text = text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, QUOTED)
59  const found: string[] = []
60  for (const segment of text.split(/\n|&&|\|\||[;|]/)) {
61    for (const m of segment.matchAll(/(?<![<>&\w])\d?&?>{1,2}\s*([^\s&|;<>]+)/g)) found.push(m[1] ?? '')
62    const tokens = segment.replace(/\d?&?>{1,2}\s*[^\s&|;<>]+/g, ' ').trim().split(/\s+/)
63    const at = tokens.findIndex(t => !/^\w+=/.test(t) && t !== 'sudo')
64    const cmd = tokens[at]?.replace(/^.*\//, '')
65    if (cmd && WRITERS[cmd]) found.push(...WRITERS[cmd](tokens.slice(at + 1)))
66  }
67  const paths = found
68    .filter(t => t !== '' && t !== '-' && !t.includes(QUOTED) && !t.includes('$') && !t.includes('*'))
69    .map(t => (t.startsWith('/') ? t : `${root}/${t.replace(/^\.\//, '')}`))
70    .filter(p => !SCRATCH.some(s => p.startsWith(s)))
71  return [...new Set(paths)]
72}
73
74// The threshold is a rule the model should know before it plans, not a surprise it meets at the
75// fourth edit. It goes into the first user message's context under this name, replaced in place
76// rather than accumulated, and refreshed whenever the threshold or a stop changes it.
77const CONTEXT_BLOCK = 'scopeGuard'
78let stops = 0
79
80// At the threshold, Haiku reads the goal, the files so far and the edit itself: an edit plainly
81// inside the goal is let through, and the turn's bar rises by one, once. Anything else (a no,
82// a malformed reply, a failed call) falls through to the ask or the deny, so the judge can only
83// ever soften the guard by one file.
84const JUDGE_MODEL = 'claude-haiku-4-5-20251001'
85const JUDGE_SYSTEM =
86  'You judge whether one file edit stays within the goal a user stated. Reply with one JSON object and nothing else: {"ok": true} or {"ok": false, "reason": "..."}.'
87let judge = true
88let judged = false
89let judgeNote = ''
90
91const verdictOf = (reply: string): { ok: boolean; reason: string } | undefined => {
92  const json = reply.match(/\{[\s\S]*\}/)
93  if (!json) return undefined
94  let parsed: unknown
95  try {
96    parsed = JSON.parse(json[0])
97  } catch {
98    return undefined
99  }
100  if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') return undefined
101  return { ok: parsed.ok, reason: 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : '' }
102}
103
104const askJudge = async ($: EngineInterface, files: string[], nextFile: string, edit: string) => {
105  const prompt = `Is this edit within the stated goal?\n${JSON.stringify({ goal, filesTouchedSoFar: files, nextFile, edit })}`
106  const reply = await $.model.complete({ model: JUDGE_MODEL, system: JUDGE_SYSTEM, prompt, maxTokens: 200 })
107  return verdictOf(reply)
108}
109
110// After a resume or a reload the module's memory is empty but the transcript is not: the goal is
111// the first human prompt in it, and the files of the turn in flight are the editing calls since
112// the last human prompt.
113const human = (m: SessionMessage) => m.role === 'user' && !m.toolResults?.length && m.text.trim() !== ''
114
115const rebuild = async ($: EngineInterface) => {
116  const messages = await $.session.messages()
117  const prompts = messages.filter(human)
118  if (!goal) goal = prompts[0]?.text ?? ''
119  const last = prompts.at(-1)
120  const since = last ? messages.slice(messages.indexOf(last) + 1) : []
121  touched = new Set(
122    since
123      .flatMap(m => m.toolUses)
124      .filter(u => EDITING_TOOLS.includes(u.tool))
125      .map(u => pathOf(u.tool, u.input))
126      .filter((p): p is string => p !== undefined),
127  )
128}
129
130const RESTATE =
131  'Do not edit anything else. Restate: (1) the goal in one sentence, (2) the branch or environment, (3) the files you expect to touch, (4) what done looks like. Then wait.'
132
133// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
134// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
135const MOD = 'scope-guard'
136let disabled = false
137const readDisabled = async ($: EngineInterface): Promise<boolean> => {
138  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
139  disabled = raw
140    .split(',')
141    .map(v => v.trim())
142    .some(v => v === 'all' || v === MOD)
143  return disabled
144}
145
146const decide = async ($: EngineInterface, e: ToolCheckInput, next: (e: ToolCheckInput) => Promise<ToolCheckResult>, paths: string[], edit: string) => {
147  if (disabled || paused || e.tool_use_id === undefined) return next(e)
148  const fresh = paths.filter(p => !touched.has(p))
149  if (fresh.length === 0) return next(e)
150
151  const below = next(e)
152  if (touched.size + fresh.length <= ceiling) {
153    const r = await below
154    if (r.decision !== 'deny') for (const p of fresh) touched.add(p)
155    $.ui.status(touched.size >= ceiling ? `scope: ${touched.size}/${ceiling} files` : undefined)
156    return r
157  }
158
159  const root = await $.session.cwd()
160  const files = [...touched].map(p => short(p, root))
161  const file = fresh.map(p => short(p, root)).join(', ')
162  const admit = () => {
163    for (const p of fresh) touched.add(p)
164    return below
165  }
166
167  if (judge && !judged) {
168    judged = true
169    $.ui.status(`scope: judging ${file}`)
170    const verdict = await askJudge($, files, file, edit).catch(err => {
171      $.ui.log(`scope-guard: judge failed: ${err}`)
172      return undefined
173    })
174    if (verdict?.ok) {
175      ceiling = touched.size + fresh.length
176      judgeNote = `A Haiku judge found the edit to ${file} within the goal and raised this turn's threshold to ${ceiling}, once.`
177      $.ui.invalidate('prompt.context')
178      $.ui.status(`scope: ${touched.size + fresh.length}/${ceiling} files (judge passed ${file})`)
179      return admit()
180    }
181    $.ui.status(undefined)
182    if (verdict && !verdict.ok) $.ui.log(`scope-guard: judge on ${file}: ${verdict.reason || 'not within the goal'}`)
183  }
184
185  const listing = files.map(f => `  ${f}`).join('\n')
186  const stop = (): ToolCheckResult => {
187    stops++
188    $.ui.invalidate('prompt.context')
189    return {
190      decision: 'deny',
191      reason: [
192        `scope-guard stopped this edit: the turn has already touched ${touched.size} files (${files.join(', ')}) and ${file} would take it past the threshold of ${ceiling}.`,
193        RESTATE,
194      ].join('\n'),
195    }
196  }
197
198  if (!interactive) {
199    // A refusal alone is answered with another tool; ending the turn is what makes the model
200    // stop and restate. The stop waits so the refusal lands in the transcript first.
201    const running = turnId
202    if (running !== undefined && aborting !== running) {
203      aborting = running
204      $.clock.after(250, () => void $.turn.abort({ turnId: running }).catch(err => $.ui.log(`scope-guard: abort failed: ${err}`)))
205    }
206    return stop()
207  }
208
209  const question = `This turn has already edited ${touched.size} files and is about to edit ${file}.\n${listing}\n\nRestate the goal before going further?`
210  const answer = await $.ui
211    .ask(question, {
212      header: 'scope-guard',
213      options: [`Stop — I'll restate the goal`, 'Continue, this is in scope', 'Off for this session'],
214    })
215    .catch(() => 'Continue, this is in scope')
216
217  if (answer === 'Off for this session') {
218    paused = true
219    return admit()
220  }
221  if (answer.startsWith('Stop')) return stop()
222  // in scope: let this turn run to double the threshold before asking again
223  ceiling = ceiling * 2
224  return admit()
225}
226
227const failClosed = ($: EngineInterface, e: ToolCheckInput, next: { error: { kind: string; message?: string } }): ToolCheckResult => ({
228  decision: 'deny',
229  reason: [
230    `scope-guard errored (${next.error.kind}${next.error.message ? `: ${next.error.message}` : ''}) and denied this edit rather than let it through unchecked.`,
231    'To disable the guard: /scope off for this session, or CLAUDE_MODS_DISABLE=scope-guard in the environment.',
232  ].join('\n'),
233})
234
235export const register: Register = on => {
236  on('session.start', async ($, e, next) => {
237    const r = await next(e)
238    if (await readDisabled($)) return r
239    interactive = e.isInteractive
240    const saved = Number(await $.store.get(THRESHOLD_KEY).catch(() => undefined))
241    if (Number.isFinite(saved) && saved > 0) threshold = saved
242    ceiling = threshold
243    judge = (await $.store.get(JUDGE_KEY).catch(() => undefined)) !== false
244    await rebuild($).catch(err => $.ui.log(`scope-guard: transcript not read: ${err}`))
245    await $.command
246      .register({
247        name: 'scope',
248        description: 'Files this turn has edited, and the threshold that stops for a restated goal (scope-guard)',
249        argumentHint: '[<number> | off | judge on|off | status]',
250        immediate: true,
251      })
252      .catch(err => $.ui.log(`scope-guard: /scope not registered: ${err}`))
253    return r
254  })
255
256  on('prompt.submit', ($, e, next) => {
257    if (!disabled && !goal && HUMAN_ORIGINS.includes(e.origin.kind) && !e.text.startsWith('/')) goal = e.text
258    return next(e)
259  })
260
261  on('command.run', { command: 'scope' }, async ($, e) => {
262    const arg = e.args.trim().toLowerCase()
263    const root = await $.session.cwd()
264    const listing = touched.size
265      ? `\nthis turn has edited ${touched.size} file(s):\n${[...touched].map(p => `  ${short(p, root)}`).join('\n')}`
266      : '\nthis turn has edited nothing yet'
267    if (arg === '' || arg === 'status') {
268      const line = `scope-guard stops at ${paused ? 'never (off)' : `${threshold} files`}; judge ${judge ? 'on' : 'off'}`
269      return { text: `${line}${goal ? `\ngoal: ${goal}` : ''}${listing}` }
270    }
271    if (arg === 'off') {
272      paused = true
273      return { text: 'scope-guard off for this session' }
274    }
275    if (arg === 'judge on' || arg === 'judge off') {
276      judge = arg === 'judge on'
277      await $.store.set(JUDGE_KEY, judge).catch(err => $.ui.log(`scope-guard: store write failed: ${err}`))
278      return { text: `scope-guard judge ${judge ? 'on: Haiku may let one in-scope edit past the threshold per turn' : 'off'}` }
279    }
280    const n = Number(arg)
281    if (!Number.isFinite(n) || n < 1) return { text: `scope: "${arg}" is not a file count, off, judge on|off, or status` }
282    threshold = Math.floor(n)
283    ceiling = threshold
284    paused = false
285    await $.store.set(THRESHOLD_KEY, threshold).catch(err => $.ui.log(`scope-guard: store write failed: ${err}`))
286    $.ui.invalidate('prompt.context')
287    return { text: `scope-guard will stop at ${threshold} files in one turn` }
288  })
289
290  on('prompt.context', async ($, e, next) => {
291    const below = await next(e)
292    if (disabled || paused) return below
293    const text = [
294      `scope-guard stops a turn that edits more than ${threshold} distinct files.`,
295      'Before a change that will touch more than that, state in one sentence: the goal, the branch or',
296      'environment, the files you expect to touch, and what done looks like — then get agreement.',
297      ...(stops ? [`This session has already been stopped ${stops === 1 ? 'once' : `${stops} times`} for scope.`] : []),
298      ...(judgeNote ? [judgeNote] : []),
299    ].join('\n')
300    return { ...below, blocks: [...below.blocks.filter(b => b.name !== CONTEXT_BLOCK), { name: CONTEXT_BLOCK, text }] }
301  })
302
303  on('turn.start', async ($, e, next) => {
304    if (disabled) return next(e)
305    touched = new Set()
306    ceiling = threshold
307    judged = false
308    judgeNote = ''
309    turnId = e.turnId
310    return next(e)
311  })
312
313  on('tool.check', { tool: EDITING_TOOLS }, ($, e, next) => {
314    const path = pathOf(e.tool, e.input)
315    return decide($, e, next, path ? [path] : [], editOf(e.input))
316  }).catch(failClosed)
317
318  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
319    const command = ((e.input ?? {}) as Record<string, unknown>).command
320    if (typeof command !== 'string' || disabled || paused || e.tool_use_id === undefined) return next(e)
321    const paths = bashTargets(command, await $.session.cwd())
322    return decide($, e, next, paths, command.slice(0, 1500))
323  }).catch(failClosed)
324}
325