SLOPSHOPPER

ask-first

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

newguardcommandtoaststatusprompt
★ 1v0.2.0Apache-2.0updated 2026-10-09Nasrallah-Adel/claude-ask-first
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ask-first
› fix the failing auth test and add an audit log call ╭──────────────────────────────────────╮ │ ask-first │ ● ask-first: ask-first: you rejected git push --force: rm -rf build && git pu│ ask-first: rejected git push --force │ ⏺ Read(src/auth.ts) ╰──────────────────────────────────────╯ ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by ask-first: ask-first held "git push --force": the user pressed Cancel. Do not retry it unless th ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ ask-first: ask-first: active ⎿ ask-first: This turn may: nothing unasked (prompt: fix the failing auth test and add an audit log call) ⎿ ask-first: Held unless asked: git commit, git push, gh pr merge, writes to .claude/settings*.json. PR-base hold: off (set ⎿ ask-first: Refused always: deleting or emptying sudoers / authorized_keys / sshd_config; removing dev from sudo, groups o ⎿ ask-first: Ship commands: /ship-pr /fast-ship-pr /edit-pr. Settings commands: /update-config /statusline /fewer-permissio ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ask-first

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.

What it does

CallWithout you askingAfter you asked (prompt or command)
git commit, git push (force too), gh pr mergeProceed / Cancel questionruns
gh pr create against a protected branch in a protected repoProceed / Cancel questionruns
Edit, Write or shell write to any .claude/settings.json / settings.local.jsonProceed / Cancel questionruns
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 commandProceed / Cancel questionProceed / 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 '...'refusedrefused

"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.

Configuration

Every option is a row in /config. Nothing is tied to one user or company.

OptionDefaultMeaning
Hold git commit / push / gh pr mergeonThe commit, push and merge holds
Hold .claude/settings*.json editsonThe settings-file hold
Protected PR base branchesmain,masterBranches a gh pr create may not target unasked
Protected repos (regex on origin URL)emptyEmpty turns the PR-base hold off; .* covers every repo; myorg/ covers one org
Accounts whose access must never be removedemptyEmpty uses $USER; a comma list names several
Commands that imply commit, push and PRship-pr,fast-ship-pr,edit-prSlash commands whose turn may commit, push and open PRs without a question
Commands that imply settings editsupdate-config,statusline,fewer-permission-prompts,configSlash 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.

Install

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.

Checking it

claude plugin validate .
claude plugin test .

The tests cover each guard, each allow path, every option, /guard and the .catch fallback.

License

Apache-2.0. See NOTICE for the parts adapted from the blast-radius example in anthropics/claude-code-playground.

Source 5 files
hooks/register.ts 232 lines
1// 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}
232
hooks/classify.ts 230 lines
1// 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}
230
hooks/config.ts 73 lines
1// 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}
73
hooks/intent.ts 71 lines
1// 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}
71
types/index.d.ts 23 lines
1// 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