SLOPSHOPPER

large-edit-confirmation

Asks the user, in the engine's AskUserQuestion dialog ($.ui.ask), before Claude edits or overwrites a file larger than a configurable line count; denies by…

newguard
A shopper browsing a rack in a slop shop
README

large-edit-confirmation

Asks the user, in the engine's own AskUserQuestion dialog, before Claude edits or overwrites a file larger than a configurable number of lines. A tool.call hook on Edit / Write: it reads the file through $.fs.read, asks through $.ui.ask, then either calls next(e) or returns { deny }.

$.ui.ask rejects in a headless (claude -p) run, where nobody can answer; the headless option decides what happens then (deny by default).

If the check itself fails before the edit runs, the edit is denied, never let through unconfirmed.

Options

  maxLines: number            files above this many lines need confirmation (default 1000)
  headless: "deny" | "allow"  what to do when there is nobody to ask (default "deny")

Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:

{ "pluginConfigs": { "large-edit-confirmation@skills-dir": { "options": { } } } }

Install

npx claude-code-templates@latest --mod security/large-edit-confirmation
claude

It is written to .claude/skills/large-edit-confirmation/, which Claude Code auto-loads as large-edit-confirmation@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/large-edit-confirmation. claude plugin validate .claude/skills/large-edit-confirmation prints every event it hooks and every $ call it makes.

Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods

Source 1 files
hooks/large-edit-confirmation.ts 99 lines
1/**
2 * large-edit-confirmation — Claude Mod
3 *
4 * Asks the user, in the engine's own AskUserQuestion dialog, before Claude
5 * edits or overwrites a file larger than a configurable number of lines.
6 * A `tool.call` hook on Edit / Write: it reads the file through `$.fs.read`,
7 * asks through `$.ui.ask`, then either calls `next(e)` or returns `{ deny }`.
8 *
9 * `$.ui.ask` rejects in a headless (`claude -p`) run, where nobody can answer;
10 * the `headless` option decides what happens then (deny by default).
11 *
12 * Needs Claude Code >= 2.1.287. Typed
13 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
14 *
15 * Options:
16 *   maxLines: number            files above this many lines need confirmation (default 1000)
17 *   headless: "deny" | "allow"  what to do when there is nobody to ask (default "deny")
18 */
19import type { Register } from 'claude-code'
20
21const ALLOW = 'Allow once'
22const DENY = 'Deny'
23
24export const register: Register = (on, options) => {
25  const threshold = typeof options.maxLines === 'number' ? options.maxLines : 1000
26  const headless = options.headless === 'allow' ? 'allow' : 'deny'
27  // whether a person is at the prompt: a dismissed dialog is a refusal; only a -p run has nobody to ask
28  let interactive = true
29
30  on('session.start', ($, e, next) => {
31    interactive = e.isInteractive
32    return next(e)
33  })
34
35  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
36    const filePath = e.file_path
37    if (!filePath) return next(e)
38
39    // A new file has nothing to protect. An existing one that cannot be read (over $.fs's 4 MiB
40    // limit, or $.fs withheld) is treated as large: this mod fails closed.
41    let exists = true
42    try {
43      exists = await $.fs.exists(filePath)
44    } catch {
45      // $.fs withheld: assume the file exists and ask
46    }
47    if (!exists) return next(e)
48    let lineCount = Infinity
49    try {
50      const current = await $.fs.read(filePath)
51      lineCount = current.split('\n').length
52    } catch {
53      // unreadable: keep Infinity and ask
54    }
55
56    if (lineCount <= threshold) return next(e)
57
58    let answer: string
59    try {
60      answer = await $.ui.ask(
61        `${filePath} has ${Number.isFinite(lineCount) ? lineCount : 'more than the readable'} lines (limit ${threshold}). Allow ${e.tool} to modify it?`,
62        [ALLOW, DENY],
63      )
64    } catch {
65      // Dismissed by the person, or a -p run with no one to ask: only the latter may allow.
66      if (!interactive && headless === 'allow') return next(e)
67      return {
68        deny: interactive
69          ? `The user dismissed the confirmation for ${e.tool} on ${filePath} (${lineCount} lines). Propose a smaller, targeted change.`
70          : `${e.tool} on ${filePath} (${lineCount} lines) needs the user's confirmation and nobody could answer. Propose a smaller, targeted change.`,
71      }
72    }
73
74    // Compare with the labels exactly: free text typed under "Other" is not an approval.
75    if (answer !== ALLOW) {
76      $.ui.log(`[large-edit-confirmation] user declined ${e.tool} on ${filePath}`)
77      return {
78        deny: `The user declined the ${e.tool} on ${filePath} (${lineCount} lines). Propose a smaller, targeted change.`,
79      }
80    }
81
82    return next(e)
83  }).catch(async ($, e, next) => {
84    // The edit already ran after an approval: hand back its result (replayed, nothing runs twice).
85    if (next.called) {
86      try {
87        return await next(e)
88      } catch {
89        return { deny: `The ${e.tool} on ${e.file_path} failed.` }
90      }
91    }
92    // Otherwise a failed check never lets the edit through unconfirmed (fail closed).
93    $.ui.log(`[large-edit-confirmation] check failed (${next.error.kind}); denied ${e.tool} on ${e.file_path}`)
94    return {
95      deny: `large-edit-confirmation could not check ${e.file_path} (${next.error.kind}), so the ${e.tool} was not run. Propose a smaller, targeted change or ask the user to make it.`,
96    }
97  })
98}
99