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…

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.
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": { } } } }
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
hooks/large-edit-confirmation.ts 99 lines1/**
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