Pauses risky shell commands and edits that match your patterns and asks you to proceed or cancel with buttons.

A small plugin ("mod") for Claude Code's terminal UI that pauses a risky shell command or file edit just before it runs and asks you to proceed or cancel with buttons.
日本語の説明は README.ja.md にあります。
path_patterns.Not for you if you run Claude non-interactively (claude -p): with no screen, the question cannot be shown and the call goes through. This mod is a convenience, not a security boundary. Keep hard rules in your permission settings or a settings hook.
When a call matches, a dialog appears before it runs:
Matched "git( -C \S+)? reset --hard": git -C /home/me/app reset --hard
Proceed?
[Proceed] [Cancel]
Proceed runs the call. Cancel (or any other answer) denies it, and Claude sees the denial reason. If no answer arrives (no screen, dialog dismissed, timeout), the call goes through.
claude plugin marketplace add i-noma-ru/claude-confirm-gate
claude plugin install confirm-gate@claude-confirm-gate
Or for one session only, from a clone:
claude --plugin-dir /path/to/claude-confirm-gate
All settings have defaults. Change them with /plugin configure confirm-gate@claude-confirm-gate.
| Setting | Default | Meaning | |
|---|---|---|---|
bash_patterns | `git( -C \S+)? push[^\n]* (--force\ | -f)\b, git( -C \S+)? reset --hard, git( -C \S+)? clean -f, git( -C \S+)? checkout -- , git( -C \S+)? restore ` | JavaScript regular expressions (case-insensitive) tested against each Bash command. A match pauses the call. The optional -C <dir> group also catches git -C /path reset --hard. Patterns are tested against the whole command text, so a matching string inside a quoted argument, a grep pattern or a comment also pauses the call. |
path_patterns | empty | Regular expressions tested against the file_path of Edit and Write calls (backslashes normalised to /). Empty ignores edits. |
An invalid regular expression is reported once with a toast when the mod loads and that entry is skipped; the others still apply.
$.ui.ask with two options and waits for the answer before letting the call continue.AskUserQuestion tool call, so other mods that react to AskUserQuestion (for example a mod that explains questions in a pane) will see it too.The command text of Bash calls and the file_path of Edit / Write calls. It reads no files and sends nothing anywhere.
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.ts 44 lines1import type { Register } from 'claude-code'
2
3import { CANCEL, classify, compile, decide, denyText, invalidText, PROCEED, questionFor } from './logic'
4
5export const register: Register = (on, options) => {
6 const compiled = compile(options)
7
8 // Report invalid patterns once per load; the valid ones stay in force.
9 on('session.start', ($, e, next) => {
10 if (compiled.invalid.length > 0) {
11 try {
12 $.ui.toast(invalidText(compiled.invalid))
13 } catch {
14 // No surface to show it on: nothing to do.
15 }
16 }
17
18 return next(e)
19 })
20
21 on('tool.call', async ($, e, next) => {
22 // A subagent's call has no screen of its own; the parent decides, so it passes.
23 if (e.agentId !== undefined) return next(e)
24
25 const hit = classify(e, compiled)
26 if (hit === null) return next(e)
27
28 let answer: unknown
29 try {
30 answer = await $.ui.ask(questionFor(hit), [PROCEED, CANCEL])
31 } catch {
32 // No surface (-p) or the question was dismissed: treated as no answer, which passes.
33 answer = undefined
34 }
35
36 if (decide(answer) === 'pass') return next(e)
37
38 return { deny: denyText(hit) }
39 }).catch(($, e, next) =>
40 // If the hook itself fails, pass rather than block; a settings hook is the real guard.
41 next(e),
42 )
43}
44hooks/logic.ts 99 lines1/** A rule that matched: the pattern's source and the command or path it matched. */
2export type GateHit = { matched: string; target: string }
3
4/** The part of a `tool.call` event the gate reads (MCP variants may carry other shapes, so unknown). */
5export type GateInput = {
6 readonly tool: string
7 readonly command?: unknown
8 readonly file_path?: unknown
9}
10
11/** A compiled pattern with the text the person wrote (RegExp.source re-escapes it). */
12export type Rule = { readonly source: string; readonly regexp: RegExp }
13
14/** The patterns compiled once from the plugin's options. */
15export type Compiled = {
16 readonly bash: readonly Rule[]
17 readonly path: readonly Rule[]
18 /** Option values `new RegExp` refused, each with its error message. */
19 readonly invalid: readonly string[]
20}
21
22export const PROCEED = 'Proceed'
23export const CANCEL = 'Cancel'
24
25const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit']
26
27/** Accepts an array of strings or a single string; anything else contributes nothing. */
28function listOf(value: unknown): string[] {
29 const items: unknown[] = ([] as unknown[]).concat(value ?? [])
30
31 return items.filter((item): item is string => typeof item === 'string' && item !== '')
32}
33
34function compileList(value: unknown, into: Rule[], invalid: string[]): void {
35 for (const source of listOf(value)) {
36 try {
37 into.push({ source, regexp: new RegExp(source, 'i') })
38 } catch (error) {
39 invalid.push(`${source}: ${error instanceof Error ? error.message : String(error)}`)
40 }
41 }
42}
43
44/** Compiles `bash_patterns` and `path_patterns`; an invalid pattern is skipped and reported in `invalid`. */
45export function compile(options: Readonly<Record<string, unknown>>): Compiled {
46 const bash: Rule[] = []
47 const path: Rule[] = []
48 const invalid: string[] = []
49
50 compileList(options['bash_patterns'], bash, invalid)
51 compileList(options['path_patterns'], path, invalid)
52
53 return { bash, path, invalid }
54}
55
56/** Windows `\` separators are compared as `/`. */
57export function normalizePath(path: string): string {
58 return path.replace(/\\/g, '/')
59}
60
61function firstMatch(rules: readonly Rule[], target: string): GateHit | null {
62 for (const rule of rules) {
63 if (rule.regexp.test(target)) return { matched: rule.source, target }
64 }
65
66 return null
67}
68
69/** The first rule that matches, or null (pure function). */
70export function classify(e: GateInput, compiled: Compiled): GateHit | null {
71 if (e.tool === 'Bash' && typeof e.command === 'string') return firstMatch(compiled.bash, e.command)
72
73 if (EDIT_TOOLS.includes(String(e.tool)) && typeof e.file_path === 'string') {
74 return firstMatch(compiled.path, normalizePath(e.file_path))
75 }
76
77 return null
78}
79
80/** The question for `$.ui.ask` (it has to end with a question mark). */
81export function questionFor(hit: GateHit): string {
82 return `Matched "${hit.matched}": ${hit.target}\nProceed?`
83}
84
85/** No answer or an empty one passes (a settings hook is the real guard); any label other than Proceed cancels. */
86export function decide(answer: unknown): 'pass' | 'deny' {
87 if (typeof answer !== 'string' || answer === '') return 'pass'
88
89 return answer === PROCEED ? 'pass' : 'deny'
90}
91
92export function denyText(hit: GateHit): string {
93 return `Cancelled by the user (confirm-gate): ${hit.matched}`
94}
95
96export function invalidText(invalid: readonly string[]): string {
97 return `confirm-gate: ${invalid.length} pattern(s) ignored (invalid regular expression): ${invalid.join('; ')}`
98}
99