Holds git commit, push, PR merge and PR creation against protected branches, and edits to .claude/settings*.json, behind a Proceed / Cancel question unless…

A Claude Code mod that makes Claude ask before doing the things you did not ask for, and refuses the few that could lock you out of your own machines.
It grew out of the corrections one developer kept typing: "why did you commit, I said don't", "why a PR from main", "every time you edit settings you disconnect yourself", and a standing rule never to drop their own SSH or sudo access on a remote box. Each of those is now a Proceed / Cancel question or a refusal, and each hold goes away the moment your prompt asks for the action.
| Call | Without you asking | After you asked (prompt or command) |
|---|---|---|
git commit, git push (force too), gh pr merge | Proceed / Cancel question | runs |
gh pr create against a protected branch in a protected repo | Proceed / Cancel question | runs |
Edit, Write or shell write to any .claude/settings.json / settings.local.json | Proceed / Cancel question | runs |
Shell edit or append (sed -i, visudo, tee -a, >>) to authorized_keys, sudoers, sshd_config; ufw / iptables rules with no allow for port 22 in the same command | Proceed / Cancel question | Proceed / Cancel question |
Delete, move or empty sudoers, authorized_keys, sshd_config (rm, mv, >, tee without -a) or the Edit / Write tool on them; deluser / userdel / gpasswd -d / usermod -G (no -a) / passwd -l / chage -E on a protected user, locally or inside ssh host '...' and bash -c '...' | refused | refused |
"Asked" means the prompt that started the turn mentions it (commit, push, ship, make/open a PR, merge, settings/hooks/statusline/permissions) or the turn was started by one of the configured ship or settings commands. A short reply ("yes", "ok", "continue", a lettered option) keeps the previous prompt's intent. Pressing Proceed grants that action for the rest of the turn, so one question covers a commit followed by an amend.
The question shows the exact command or file path. After you answer, a transcript line records it, for example ask-first: you approved git commit: git commit -m "fix tests" or ask-first: you rejected git push: git push origin main.
A refusal tells the model why and not to retry, so the turn continues with the rest of the work.
Every option is a row in /config. Nothing is tied to one user or company.
| Option | Default | Meaning |
|---|---|---|
| Hold git commit / push / gh pr merge | on | The commit, push and merge holds |
| Hold .claude/settings*.json edits | on | The settings-file hold |
| Protected PR base branches | main,master | Branches a gh pr create may not target unasked |
| Protected repos (regex on origin URL) | empty | Empty turns the PR-base hold off; .* covers every repo; myorg/ covers one org |
| Accounts whose access must never be removed | empty | Empty uses $USER; a comma list names several |
| Commands that imply commit, push and PR | ship-pr,fast-ship-pr,edit-pr | Slash commands whose turn may commit, push and open PRs without a question |
| Commands that imply settings edits | update-config,statusline,fewer-permission-prompts,config | Slash commands whose turn may edit settings files |
/guard shows whether the mod is active, what this turn may do, and the configuration in force./guard off pauses the holds across sessions until /guard on. Refusals stay on.If you also run the blast-radius mod, a force push gets both questions: this mod's push hold and blast-radius's measured report.
In a Claude Code terminal session:
/plugin install ask-first --marketplace Nasrallah-Adel/claude-ask-first
Answer y to add the marketplace, pick a scope, and set the options. Or clone this repo into ~/.claude/skills/ask-first, where mods load on their own.
claude plugin validate .
claude plugin test .
The tests cover each guard, each allow path, every option, /guard and the .catch fallback.
Apache-2.0. See NOTICE for the parts adapted from the blast-radius example in anthropics/claude-code-playground.
hooks/register.ts 232 lines1// ask-first: holds the tool calls people keep having to undo, and refuses the
2// ones that could lock them out of their own machines.
3//
4// Holds (Proceed / Cancel via $.ui.ask, skipped when the prompt or the slash
5// command asked for it): git commit, git push, gh pr merge, gh pr create
6// against a protected branch in a protected repo, and any write to a
7// .claude/settings*.json. Refuses outright: deleting or emptying sudoers,
8// authorized_keys or sshd_config, and account changes that remove a protected
9// user's access.
10//
11// The host reads `on(...)` and `$.noun.method(...)` from source, so hooks are
12// inline arrows and every helper that takes `$` is a top-level function.
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { AskFirstIntent } from '../types'
17import { ACCESS_PATH, SETTINGS_PATH, classify } from './classify'
18import type { Finding } from './classify'
19import { DEFAULT_CONFIG, configFrom } from './config'
20import type { GuardConfig } from './config'
21import { EMPTY_INTENT, intentFromCommand, intentFromPrompt, isAffirmation, mergeIntent } from './intent'
22
23const NAME = 'ask-first'
24const PAUSED_KEY = 'isPaused'
25const PROCEED = 'Proceed'
26const CANCEL = 'Cancel'
27const DO_NOT_RETRY = 'Do not retry it unless the user asks you to.'
28const MAX_SHOWN = 300
29const FAILED = `${NAME}: its guard failed or the question was dismissed, so the call did not run. ${DO_NOT_RETRY}`
30
31const intent = atom({ plugin: 'ask-first', key: 'intent' } as const, EMPTY_INTENT)
32
33type Hold = { what: string; grants: Partial<AskFirstIntent> }
34type Deny = { deny: string }
35
36// Set by register() from userConfig; a reload runs register() again.
37let config: GuardConfig = DEFAULT_CONFIG
38// The configured protected users, or $USER when none are configured; read at session.start.
39let protectedUsers: readonly string[] = []
40
41export const register: Register = (on, options) => {
42 config = configFrom(options)
43 protectedUsers = config.protectedUsers
44
45 on('session.start', async ($, e, next) => {
46 if (config.protectedUsers.length === 0) {
47 const user = await $.env.get('USER')
48 protectedUsers = user === undefined || user === '' ? [] : [user]
49 }
50 await $.command.register({
51 name: 'guard',
52 description: `${NAME}: show what this turn is allowed to do; "off" pauses the holds, "on" resumes them`,
53 argumentHint: '[on|off]',
54 })
55 return next(e)
56 })
57
58 on('command.run', ($, e, next) => grantForCommand($, e.command, () => next(e)))
59 on('command.run', { command: 'guard' }, ($, e) => runGuardCommand($, e.args))
60
61 on('prompt.submit', async ($, e, next) => {
62 if (!isAffirmation(e.text)) {
63 await update($, intent, () => intentFromPrompt(e.text))
64 }
65 return next(e)
66 })
67
68 on('tool.call', { tool: 'Bash' }, ($, e, next) => guardShell($, e.command, () => next(e))).catch(($, e, next) =>
69 next.called ? next(e) : { deny: FAILED },
70 )
71 on('tool.call', { tool: 'Edit' }, ($, e, next) => guardFile($, e.file_path, () => next(e))).catch(($, e, next) =>
72 next.called ? next(e) : { deny: FAILED },
73 )
74 on('tool.call', { tool: 'Write' }, ($, e, next) => guardFile($, e.file_path, () => next(e))).catch(($, e, next) =>
75 next.called ? next(e) : { deny: FAILED },
76 )
77}
78
79function accessRule(): string {
80 const who = protectedUsers.length === 0 ? 'the owner' : protectedUsers.join(' / ')
81 return `Rule: never remove ${who}'s own root, sudo or SSH access on any machine.`
82}
83
84/** `/guard`, `/guard on`, `/guard off`. */
85async function runGuardCommand($: EngineInterface, args: string): Promise<{ text: string }> {
86 const arg = args.trim().toLowerCase()
87 if (arg === 'off') {
88 await $.store.set(PAUSED_KEY, true)
89 return { text: `${NAME} paused: commits, pushes, PRs and settings edits are not held. Access-loss commands are still refused. /guard on resumes.` }
90 }
91 if (arg === 'on') {
92 await $.store.set(PAUSED_KEY, false)
93 return { text: `${NAME} active.` }
94 }
95 const current = await read($, intent)
96 const paused = await isPaused($)
97 const allowed = (['commit', 'push', 'pr', 'merge', 'settings'] as const).filter(k => current[k]).join(', ')
98 const repos = config.protectedRepo === null ? 'off (set "Protected repos" in /config)' : `${config.protectedRepoPattern} → ${[...config.protectedBranches].join(', ')}`
99 return {
100 text: [
101 `${NAME}: ${paused ? 'paused (/guard on resumes)' : 'active'}`,
102 `This turn may: ${allowed === '' ? 'nothing unasked' : allowed}${current.source === '' ? '' : ` (${current.source})`}`,
103 `Held unless asked: git commit, git push, gh pr merge, writes to .claude/settings*.json. PR-base hold: ${repos}.`,
104 `Refused always: deleting or emptying sudoers / authorized_keys / sshd_config; removing ${protectedUsers.length === 0 ? '(no user configured)' : protectedUsers.join(', ')} from sudo, groups or login.`,
105 `Ship commands: ${[...config.shipCommands].map(c => `/${c}`).join(' ')}. Settings commands: ${[...config.settingsCommands].map(c => `/${c}`).join(' ')}.`,
106 ].join('\n'),
107 }
108}
109
110/** A slash command that implies commits, pushes or settings edits grants them for its turn. */
111async function grantForCommand<R>($: EngineInterface, command: string, run: () => Promise<R>): Promise<R> {
112 const grant = intentFromCommand(command, config)
113 if (grant !== null) {
114 await update($, intent, current => mergeIntent(current, grant))
115 }
116 return run()
117}
118
119/** The Bash guard: refuse access-loss commands, hold the unasked rest. */
120async function guardShell<R>($: EngineInterface, command: string, run: () => Promise<R>): Promise<R | Deny> {
121 const findings = classify(command, protectedUsers)
122 if (findings.length === 0) return run()
123
124 const reasons = findings.flatMap(f => (f.kind === 'access-deny' ? [f.reason] : []))
125 if (reasons.length > 0) {
126 $.ui.toast(`${NAME}: refused ${reasons.join('; ')}`)
127 return { deny: `${NAME} refused this command: ${reasons.join('; ')}. ${accessRule()} ${DO_NOT_RETRY}` }
128 }
129 if (await isPaused($)) return run()
130
131 const holds = await holdsFor($, findings, await read($, intent))
132 if (holds.length === 0) return run()
133 return hold($, holds, command, run)
134}
135
136/** The Edit / Write guard: refuse access files, hold unasked settings edits. */
137async function guardFile<R>($: EngineInterface, filePath: string, run: () => Promise<R>): Promise<R | Deny> {
138 const path = await resolvePath($, filePath)
139 if (ACCESS_PATH.test(path)) {
140 $.ui.toast(`${NAME}: refused edit of ${path}`)
141 return { deny: `${NAME} refused editing ${path} with this tool. ${accessRule()} ${DO_NOT_RETRY}` }
142 }
143 if (!config.holdSettings || !SETTINGS_PATH.test(path)) return run()
144 if (await isPaused($)) return run()
145 if ((await read($, intent)).settings) return run()
146 return hold($, [{ what: `edit ${path} (a settings edit reloads the harness mid-turn)`, grants: { settings: true } }], path, run)
147}
148
149/** Which of the findings need a Proceed / Cancel, given what the user asked for. */
150async function holdsFor($: EngineInterface, findings: readonly Finding[], current: AskFirstIntent): Promise<readonly Hold[]> {
151 const holds: Hold[] = []
152 for (const f of findings) {
153 if (f.kind === 'git-commit' && config.holdCommits && !current.commit) {
154 holds.push({ what: 'git commit', grants: { commit: true } })
155 }
156 if (f.kind === 'git-push' && config.holdCommits && !current.push) {
157 holds.push({ what: f.isForce ? 'git push --force' : 'git push', grants: { push: true } })
158 }
159 if (f.kind === 'gh-pr-merge' && config.holdCommits && !current.merge && !current.push) {
160 holds.push({ what: 'gh pr merge', grants: { merge: true } })
161 }
162 if (f.kind === 'gh-pr-create' && !current.pr && (await targetsProtectedBranch($, f.base))) {
163 holds.push({ what: `open a PR against ${f.base ?? 'the default branch'} (a protected branch of this repo)`, grants: { pr: true } })
164 }
165 if (f.kind === 'settings-write' && config.holdSettings && !current.settings) {
166 holds.push({ what: `write ${f.path} (a settings edit reloads the harness mid-turn)`, grants: { settings: true } })
167 }
168 if (f.kind === 'access-hold') {
169 holds.push({ what: `${f.reason} (this could lock you out)`, grants: {} })
170 }
171 }
172 return holds
173}
174
175async function targetsProtectedBranch($: EngineInterface, base: string | null): Promise<boolean> {
176 if (config.protectedRepo === null) return false
177 if (base !== null && !config.protectedBranches.has(base)) return false
178 try {
179 const repo = await $.session.repo()
180 return config.protectedRepo.test(repo?.remote ?? '')
181 } catch {
182 return true
183 }
184}
185
186/** Asks Proceed / Cancel, showing the exact call; logs the answer; Proceed runs the call and grants its flags for the turn. */
187async function hold<R>($: EngineInterface, holds: readonly Hold[], detail: string, run: () => Promise<R>): Promise<R | Deny> {
188 const what = holds.map(h => h.what).join('; ')
189 const shown = shorten(detail)
190 $.ui.status(`${NAME}: waiting on you`)
191 try {
192 const answer = await $.ui.ask(`${NAME}: Claude is about to ${what}, which you did not ask for this turn.\n\n${shown}\n\nProceed?`, {
193 options: [PROCEED, CANCEL],
194 header: 'Ask first',
195 })
196 if (answer !== PROCEED) {
197 $.ui.log(`${NAME}: you rejected ${what}: ${shown}`)
198 $.ui.toast(`${NAME}: rejected ${what}`)
199 return { deny: `${NAME} held "${what}": the user pressed Cancel. ${DO_NOT_RETRY}` }
200 }
201 $.ui.log(`${NAME}: you approved ${what}: ${shown}`)
202 $.ui.toast(`${NAME}: approved ${what}`)
203 const grants = holds.reduce<Partial<AskFirstIntent>>((all, h) => ({ ...all, ...h.grants }), {})
204 await update($, intent, current => mergeIntent(current, { ...grants, source: `${current.source} + Proceed` }))
205 return run()
206 } finally {
207 $.ui.status(undefined)
208 }
209}
210
211/** The call as shown in the dialog and the log: one trimmed string, cut at MAX_SHOWN characters. */
212function shorten(detail: string): string {
213 const trimmed = detail.trim()
214 return trimmed.length <= MAX_SHOWN ? trimmed : `${trimmed.slice(0, MAX_SHOWN)}…`
215}
216
217async function isPaused($: EngineInterface): Promise<boolean> {
218 return (await $.store.get(PAUSED_KEY)) === true
219}
220
221/** `~` expanded and symlinks followed where the file exists, else the path as spelled. */
222async function resolvePath($: EngineInterface, path: string): Promise<string> {
223 const home = (await $.env.get('HOME')) ?? ''
224 const spelled = path.startsWith('~/') && home !== '' ? `${home}${path.slice(1)}` : path
225 try {
226 const stat = await $.fs.stat(spelled, { resolve: true })
227 return stat.realPath ?? spelled
228 } catch {
229 return spelled
230 }
231}
232hooks/classify.ts 230 lines1// Reads a shell command line and names what ask-first cares about in it.
2//
3// The segment splitter, prefix stripping and git option walk are ported from
4// the blast-radius mod (Apache-2.0), trimmed to what this mod gates. Like
5// there, the split is crude on purpose: it also cuts inside quotes, so a
6// command sent over `ssh host 'a && b'` is read segment by segment too.
7
8export type Finding =
9 | { kind: 'git-commit' }
10 | { kind: 'git-push'; isForce: boolean }
11 | { kind: 'gh-pr-create'; base: string | null }
12 | { kind: 'gh-pr-merge' }
13 | { kind: 'settings-write'; path: string }
14 | { kind: 'access-deny'; reason: string }
15 | { kind: 'access-hold'; reason: string }
16
17export const SETTINGS_PATH = /(^|\/)\.claude\/settings(\.local)?\.json$/
18export const ACCESS_PATH = /\/etc\/sudoers(\.d\/[^\s'"]*)?|authorized_keys|\/etc\/ssh\/sshd_config(\.d\/[^\s'"]*)?/
19
20
21// sudo options that take a value, so the value isn't read as the command.
22const SUDO_VALUE_OPTIONS: ReadonlySet<string> = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
23// Words that can come before the real command without changing what it does.
24const PREFIXES: ReadonlySet<string> = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
25const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
26// Commands that write the file they are given.
27const FILE_WRITERS: ReadonlySet<string> = new Set(['tee', 'cp', 'mv', 'rm', 'truncate', 'install', 'ed', 'visudo'])
28const INPLACE_EDITORS: ReadonlySet<string> = new Set(['sed', 'perl'])
29// Of those, the ones that take an access file away outright, and the ones that edit it in place.
30const FILE_REMOVERS: ReadonlySet<string> = new Set(['rm', 'mv', 'truncate', 'cp', 'install'])
31const FILE_EDITORS: ReadonlySet<string> = new Set(['ed', 'visudo'])
32const USER_REMOVERS: ReadonlySet<string> = new Set(['deluser', 'userdel'])
33const SSH_ALLOW = /\ballow\b[^&|;\n]*\b(22|ssh|openssh)\b/i
34
35/**
36 * Every finding in `command`, in the order its segments run; `users` are the
37 * accounts whose access must never be removed.
38 */
39export function classify(command: string, users: readonly string[]): readonly Finding[] {
40 const segments = command.split(/&&|\|\||;|\||\n/)
41 const found = segments.flatMap(segment => classifySegment(segment, command, users))
42 return dedupe(found)
43}
44
45function dedupe(findings: readonly Finding[]): readonly Finding[] {
46 const seen = new Set<string>()
47 return findings.filter(f => {
48 const key = JSON.stringify(f)
49 if (seen.has(key)) return false
50 seen.add(key)
51 return true
52 })
53}
54
55function classifySegment(raw: string, whole: string, users: readonly string[]): readonly Finding[] {
56 const trimmed = raw.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, '')
57 const words = stripPrefixes(tokenize(trimmed))
58 const [first, ...args] = words
59 if (first === undefined) return []
60 const cmd = first.replace(/^\\/, '').replace(/^.*\//, '')
61 const nested = nestedCommand(cmd, args)
62 if (nested !== null) return classify(nested, users)
63 return [
64 ...gitFindings(cmd, args),
65 ...ghFindings(cmd, args),
66 ...settingsFindings(cmd, args, raw),
67 ...accessFindings(cmd, args, raw, whole, users),
68 ]
69}
70
71// ssh options that take a value, so the value isn't read as the host.
72const SSH_VALUE_OPTIONS: ReadonlySet<string> = new Set(['-p', '-l', '-i', '-o', '-J', '-F', '-L', '-R', '-D', '-W', '-b', '-c', '-e', '-m', '-O', '-Q', '-S', '-E', '-B', '-I', '-w'])
73const SHELLS: ReadonlySet<string> = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
74
75/** The command a `ssh host '...'` or `bash -c '...'` runs, so it is read like a local one; null otherwise. */
76function nestedCommand(cmd: string, args: readonly string[]): string | null {
77 if (cmd === 'ssh') {
78 let i = 0
79 while (i < args.length && (args[i] ?? '').startsWith('-')) {
80 i += SSH_VALUE_OPTIONS.has(args[i] ?? '') ? 2 : 1
81 }
82 const remote = args.slice(i + 1)
83 return remote.length === 0 ? null : remote.join(' ')
84 }
85 if (SHELLS.has(cmd)) {
86 const at = args.findIndex(a => a === '-c' || /^-[a-zA-Z]*c$/.test(a))
87 const script = at === -1 ? undefined : args[at + 1]
88 return script === undefined ? null : script
89 }
90 return null
91}
92
93/** Drops VAR=value, sudo and its options, and the wrapper words before the command. */
94function stripPrefixes(words: readonly string[]): readonly string[] {
95 let rest = [...words]
96 while (rest.length > 0 && ASSIGNMENT.test(rest[0] ?? '')) rest = rest.slice(1)
97 if (rest[0] === 'sudo') {
98 rest = rest.slice(1)
99 while (rest.length > 0 && (rest[0] ?? '').startsWith('-')) {
100 const option = rest[0] ?? ''
101 rest = rest.slice(SUDO_VALUE_OPTIONS.has(option) ? 2 : 1)
102 }
103 }
104 while (rest.length > 0 && (PREFIXES.has(rest[0] ?? '') || ASSIGNMENT.test(rest[0] ?? ''))) rest = rest.slice(1)
105 return rest
106}
107
108/** `git commit`, `git push`; `-C dir` and `-c k=v` before the subcommand are skipped. */
109function gitFindings(cmd: string, args: readonly string[]): readonly Finding[] {
110 if (cmd !== 'git') return []
111 let i = 0
112 while (i < args.length && (args[i] ?? '').startsWith('-')) {
113 const option = args[i] ?? ''
114 i += option === '-C' || option === '-c' ? 2 : 1
115 }
116 const sub = args[i]
117 const rest = args.slice(i + 1)
118 if (sub === 'commit') return [{ kind: 'git-commit' }]
119 if (sub === 'push') {
120 const isForce = rest.some(a => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || /^\+/.test(a))
121 return [{ kind: 'git-push', isForce }]
122 }
123 return []
124}
125
126/** `gh pr create [--base X]` and `gh pr merge`. */
127function ghFindings(cmd: string, args: readonly string[]): readonly Finding[] {
128 if (cmd !== 'gh' || args[0] !== 'pr') return []
129 if (args[1] === 'merge') return [{ kind: 'gh-pr-merge' }]
130 if (args[1] !== 'create') return []
131 return [{ kind: 'gh-pr-create', base: baseOf(args.slice(2)) }]
132}
133
134function baseOf(args: readonly string[]): string | null {
135 for (let i = 0; i < args.length; i += 1) {
136 const a = args[i] ?? ''
137 if (a === '--base' || a === '-B') return args[i + 1] ?? null
138 if (a.startsWith('--base=')) return a.slice('--base='.length)
139 }
140 return null
141}
142
143/** A write to a `.claude/settings*.json`: by redirect, by a writing command, or by an in-place editor. */
144function settingsFindings(cmd: string, args: readonly string[], raw: string): readonly Finding[] {
145 const target = redirectTarget(raw)
146 if (target !== null && SETTINGS_PATH.test(target)) return [{ kind: 'settings-write', path: target }]
147 const named = args.find(a => SETTINGS_PATH.test(a))
148 if (named === undefined) return []
149 if (FILE_WRITERS.has(cmd)) return [{ kind: 'settings-write', path: named }]
150 if (INPLACE_EDITORS.has(cmd) && args.some(a => /^-[a-zA-Z]*i/.test(a))) return [{ kind: 'settings-write', path: named }]
151 return []
152}
153
154/** The file a `>` or `>>` in the segment writes to, or null. */
155function redirectTarget(raw: string): string | null {
156 const m = /(?:^|[^<>&\d])\d?>{1,2}\s*([^\s&|;]+)/.exec(raw)
157 return m?.[1] ?? null
158}
159
160function isTruncatingRedirect(raw: string): boolean {
161 return /(?:^|[^<>&\d])\d?>(?!>)\s*[^\s&|;]+/.test(raw)
162}
163
164/**
165 * Changes that could drop the owner's own root, sudo or SSH access on a box:
166 * removal, truncation or in-place editing of sudoers, authorized_keys or
167 * sshd_config is refused; an append to them, or a firewall rule that may close
168 * port 22, is held for a Proceed / Cancel.
169 */
170function accessFindings(cmd: string, args: readonly string[], raw: string, whole: string, users: readonly string[]): readonly Finding[] {
171 const findings: Finding[] = []
172 const accessFile = [...args, redirectTarget(raw) ?? ''].find(a => ACCESS_PATH.test(a))
173 if (accessFile !== undefined) {
174 // Removal and truncation cannot be undone from a locked-out box: refused.
175 // An edit in place, a tee or an append may still be the asked-for work
176 // (adding a key, turning off password auth): held for a Proceed / Cancel.
177 const removes = FILE_REMOVERS.has(cmd)
178 const redirectTruncates = isTruncatingRedirect(raw) && ACCESS_PATH.test(redirectTarget(raw) ?? '')
179 const teeTruncates = cmd === 'tee' && !args.some(a => a === '-a' || a === '--append')
180 const edits = (INPLACE_EDITORS.has(cmd) && args.some(a => /^-[a-zA-Z]*i/.test(a))) || FILE_EDITORS.has(cmd) || cmd === 'tee'
181 const appends = /(?:^|[^>])>>\s*\S/.test(raw)
182 if (removes || redirectTruncates || teeTruncates) {
183 findings.push({ kind: 'access-deny', reason: `${cmd} removes or empties ${accessFile}` })
184 } else if (edits || appends) {
185 findings.push({ kind: 'access-hold', reason: `${edits ? 'edit' : 'append to'} ${accessFile}` })
186 }
187 }
188 const user = users.find(u => args.includes(u) || args.some(a => a.endsWith(`:${u}`) || a.startsWith(`${u}:`)))
189 if (user !== undefined) {
190 if (USER_REMOVERS.has(cmd)) findings.push({ kind: 'access-deny', reason: `${cmd} on ${user}` })
191 if (cmd === 'gpasswd' && args.includes('-d')) findings.push({ kind: 'access-deny', reason: `gpasswd -d on ${user}` })
192 if (cmd === 'passwd' && args.some(a => a === '-l' || a === '--lock')) findings.push({ kind: 'access-deny', reason: `passwd lock on ${user}` })
193 if (cmd === 'chage' && args.some(a => a === '-E' || a === '--expiredate')) findings.push({ kind: 'access-deny', reason: `chage -E on ${user}` })
194 if (cmd === 'usermod' && usermodRemovesAccess(args)) findings.push({ kind: 'access-deny', reason: `usermod removes access from ${user}` })
195 if (cmd === 'chsh' && args.some(a => /nologin|\/bin\/false/.test(a))) findings.push({ kind: 'access-deny', reason: `chsh to a no-login shell for ${user}` })
196 }
197 if (cmd === 'ufw' && !SSH_ALLOW.test(whole)) {
198 const joined = args.join(' ')
199 if (/^(--force\s+)?(reset|enable)\b|\bdeny\b[^&]*\b(22|ssh|openssh)\b|\bdelete\s+allow\b[^&]*\b(22|ssh|openssh)\b|\bdefault\s+deny\b/i.test(joined)) {
200 findings.push({ kind: 'access-hold', reason: `ufw ${joined} with no allow for port 22 in the same command` })
201 }
202 }
203 if (cmd === 'iptables' || cmd === 'ip6tables' || cmd === 'nft') {
204 const joined = args.join(' ')
205 if (/-P\s+INPUT\s+DROP|(^|\s)-F(\s|$)|--dport\s+22\b.*-j\s+(DROP|REJECT)|flush ruleset/i.test(joined)) {
206 findings.push({ kind: 'access-hold', reason: `${cmd} ${joined} may close SSH` })
207 }
208 }
209 return findings
210}
211
212function usermodRemovesAccess(args: readonly string[]): boolean {
213 const appends = args.some(a => a === '-a' || a === '--append' || /^-[a-zA-Z]*a/.test(a))
214 const setsGroups = args.some(a => a === '-G' || a === '--groups' || /^-[a-zA-Z]*G/.test(a))
215 const locks = args.some(a => a === '-L' || a === '--lock' || a === '-e' || a === '--expiredate')
216 const noLogin = args.some(a => /nologin|\/bin\/false/.test(a))
217 return (setsGroups && !appends) || locks || noLogin
218}
219
220/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
221export function tokenize(text: string): readonly string[] {
222 const words: string[] = []
223 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
224 let m: RegExpExecArray | null
225 while ((m = re.exec(text)) !== null) {
226 words.push(m[1] ?? m[2] ?? m[3] ?? '')
227 }
228 return words
229}
230hooks/config.ts 73 lines1// The mod's options as register() receives them, parsed once into the shapes
2// the guards read.
3import type { PluginOptions } from 'claude-code'
4
5export type GuardConfig = {
6 holdCommits: boolean
7 holdSettings: boolean
8 /** PR base branches that are held in a protected repo. */
9 protectedBranches: ReadonlySet<string>
10 /** Matched against the origin remote; null turns the PR-base hold off. */
11 protectedRepo: RegExp | null
12 /** The pattern as configured, for display. */
13 protectedRepoPattern: string
14 /** Accounts whose access must never be removed; empty means "use $USER". */
15 protectedUsers: readonly string[]
16 /** Slash commands whose turn may commit, push, open and merge PRs. */
17 shipCommands: ReadonlySet<string>
18 /** Slash commands whose turn may edit .claude/settings*.json. */
19 settingsCommands: ReadonlySet<string>
20}
21
22export const DEFAULT_SHIP_COMMANDS = 'ship-pr,fast-ship-pr,edit-pr'
23export const DEFAULT_SETTINGS_COMMANDS = 'update-config,statusline,fewer-permission-prompts,config'
24export const DEFAULT_BRANCHES = 'main,master'
25
26export const DEFAULT_CONFIG: GuardConfig = {
27 holdCommits: true,
28 holdSettings: true,
29 protectedBranches: parseList(DEFAULT_BRANCHES),
30 protectedRepo: null,
31 protectedRepoPattern: '',
32 protectedUsers: [],
33 shipCommands: parseList(DEFAULT_SHIP_COMMANDS),
34 settingsCommands: parseList(DEFAULT_SETTINGS_COMMANDS),
35}
36
37/** The config the manifest's userConfig values describe, defaults filled in. */
38export function configFrom(options: PluginOptions): GuardConfig {
39 return {
40 holdCommits: options.holdCommits !== false,
41 holdSettings: options.holdSettings !== false,
42 protectedBranches: parseList(stringOr(options.protectedBranches, DEFAULT_BRANCHES)),
43 protectedRepo: regExpOrNull(stringOr(options.protectedRepoPattern, '')),
44 protectedRepoPattern: stringOr(options.protectedRepoPattern, ''),
45 protectedUsers: [...parseList(stringOr(options.protectedUsers, ''))],
46 shipCommands: parseList(stringOr(options.shipCommands, DEFAULT_SHIP_COMMANDS)),
47 settingsCommands: parseList(stringOr(options.settingsCommands, DEFAULT_SETTINGS_COMMANDS)),
48 }
49}
50
51/** A comma-separated list as a set of trimmed, non-empty entries. */
52export function parseList(value: string): ReadonlySet<string> {
53 return new Set(
54 value
55 .split(',')
56 .map(s => s.trim())
57 .filter(s => s !== ''),
58 )
59}
60
61function stringOr(value: unknown, fallback: string): string {
62 return typeof value === 'string' ? value : fallback
63}
64
65function regExpOrNull(source: string): RegExp | null {
66 if (source.trim() === '') return null
67 try {
68 return new RegExp(source, 'i')
69 } catch {
70 return null
71 }
72}
73hooks/intent.ts 71 lines1// What the user asked for this turn, read from their prompt or the slash
2// command they ran. A guard lets a tool call through when the matching flag is
3// set, so "commit this" is never held and a stray commit always is.
4import type { AskFirstIntent as Intent } from '../types'
5import type { GuardConfig } from './config'
6
7export const EMPTY_INTENT: Intent = {
8 commit: false,
9 push: false,
10 pr: false,
11 merge: false,
12 settings: false,
13 source: '',
14}
15
16const COMMIT = /\bcomm?its?\b/i
17const PUSH = /\bpush(ed|es|ing)?\b|\bship\b/i
18const PR_VERB = '(make|create|open|ship|raise|submit|send)'
19const PR_NOUN = "(prs?'?s?|pull[- ]requests?)"
20const PR = new RegExp(`\\b${PR_VERB}\\b[^.\\n]{0,40}\\b${PR_NOUN}\\b|\\b${PR_NOUN}\\b[^.\\n]{0,40}\\b${PR_VERB}\\b`, 'i')
21const MERGE = /\bmerge[sd]?\b/i
22const SETTINGS = /settings(\.local)?\.json|\bstatus ?line\b|\bhooks?\b|\bpermissions?\b|allowed ?tools|pluginConfigs|claude (code )?settings|settings file|\/config\b/i
23
24// Short replies that continue the previous ask rather than start a new one:
25// "yes", "ok", "continue", a lettered or numbered option, and the Arabic
26// keyboard-layout spellings of "yes" and "next" typed on a QWERTY layout.
27const AFFIRMATION = /^\s*(y|yes|yep|yeah|ok|okay|sure|go|go ahead|do it|proceed|continue|next|done|yes please|ok go|غثس|ثءهف|[a-h]|[1-4])\s*[.!]?\s*$/i
28const AFFIRMATION_MAX_LENGTH = 25
29
30/** The intent a freshly typed prompt carries. */
31export function intentFromPrompt(text: string): Intent {
32 const head = text.trim().slice(0, 60).replace(/\s+/g, ' ')
33 const pr = PR.test(text)
34 return {
35 commit: COMMIT.test(text),
36 push: PUSH.test(text) || pr,
37 pr,
38 merge: MERGE.test(text),
39 settings: SETTINGS.test(text),
40 source: head === '' ? '' : `prompt: ${head}`,
41 }
42}
43
44/** True for a reply that answers a question rather than asking for new work. */
45export function isAffirmation(text: string): boolean {
46 return text.length <= AFFIRMATION_MAX_LENGTH && AFFIRMATION.test(text)
47}
48
49/** The flags a slash command grants for the turn it starts, or null for one that grants none. */
50export function intentFromCommand(command: string, config: GuardConfig): Partial<Intent> | null {
51 if (config.shipCommands.has(command)) {
52 return { commit: true, push: true, pr: true, merge: true, source: `command: /${command}` }
53 }
54 if (config.settingsCommands.has(command)) {
55 return { settings: true, source: `command: /${command}` }
56 }
57 return null
58}
59
60/** A new intent with `grant`'s true flags added to `base`. */
61export function mergeIntent(base: Intent, grant: Partial<Intent>): Intent {
62 return {
63 commit: base.commit || grant.commit === true,
64 push: base.push || grant.push === true,
65 pr: base.pr || grant.pr === true,
66 merge: base.merge || grant.merge === true,
67 settings: base.settings || grant.settings === true,
68 source: grant.source ?? base.source,
69 }
70}
71types/index.d.ts 23 lines1// ask-first's state contract: the intent read from the current prompt, which
2// the guards consult before holding a tool call.
3export type AskFirstIntent = {
4 /** The user asked for a commit this turn. */
5 commit: boolean
6 /** The user asked for a push, or ran a ship command. */
7 push: boolean
8 /** The user asked for a pull request to be created. */
9 pr: boolean
10 /** The user asked for a merge. */
11 merge: boolean
12 /** The user asked for a settings / hooks / statusline change. */
13 settings: boolean
14 /** Where the intent came from: a prompt's first words or a command's name. */
15 source: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'ask-first': { intent: AskFirstIntent }
21 }
22}
23