Puts every command that changes the machine in front of the user before it runs

Stops Claude from changing your machine behind your back. Before a command that installs software, needs root, downloads images or edits global configuration runs, a dialog shows you the command and the reason it was caught:
Claude wants to run a command that changes this machine (brew install changes installed packages):
brew install jq
Allow it?
❯ Allow once
Deny
Allow once runs it. Deny refuses it, and Claude is told to give you the command instead. Typing your own answer, such as "use mise instead", refuses it and passes your words to Claude.
The dialog is shown to you directly, also in auto mode. A regular permission "ask" would be settled by auto mode's own reviewer, which may approve it without you.
No setup needed.
| Caught | Let through | |
|---|---|---|
sudo, `curl … \ | sh` | read-only commands such as brew list, docker ps |
brew install, upgrade, uninstall, tap, bundle | project dependencies: npm install, npm ci, pnpm install | |
global npm, pnpm, yarn, bun installs | pip inside a virtual environment (.venv/bin/pip) | |
pip outside a virtual environment, pipx, uv tool, cargo install, go install, gem install, dotnet tool install -g | starting and stopping services: colima start, docker compose up, docker run, brew services start, launchctl load | |
mise install and use, asdf, rustup | git config without --global | |
docker pull, build, create, docker compose pull, build | ||
colima delete, podman machine init and rm, launchctl enable | ||
defaults write, writing git config --global, xcode-select --install, softwareupdate, winget, choco, scoop |
Chained commands are checked part by part: in cd app && brew install jq the second part is caught. Text inside quotes is data, so searching for install commands — grep 'brew install\|cargo install' log — is let through.
Starting a service changes nothing permanent and is left to service-radar, which keeps track of what Claude started and offers to stop it. Use an ask rule (below) where starting something should still be confirmed.
An optional .claude/machine-guard.json in a project adds rules for that project. match is a regular expression tested against each part of a command.
{
"ask": [
{ "match": "^dotnet (run|watch)\\b", "reason": "Starts a local instance without data" }
],
"block": [
{ "match": "^rm -rf\\b", "reason": "Never delete recursively in this project" }
]
}
| Level | Effect |
|---|---|
| built in | the dialog for the commands in the table above, in every project |
ask | the dialog with reason shown, also for commands the built-in rules let through |
block | refused without a dialog; Claude receives reason |
A command caught by a built-in rule and an ask rule shows one dialog with both reasons.
The guard recognises commands, not intentions. An installer it does not know, a script such as bash install.sh that installs internally, or a file Claude writes outside the project with its Write tool are not caught. Keep an instruction in your CLAUDE.md that Claude must not install anything unasked; the guard is the safety net under it.
Heredoc bodies are data for the program they are fed to, so text that python3, cat or tee writes into a file is not checked — documentation that mentions brew install causes no dialog. A heredoc fed to a shell (bash <<EOF, cat <<EOF | sh) is still checked line by line.
Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Project files .claude/machine-guard.json are ignored without it.
hooks/register.ts 81 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { findMachineChanges, findProjectMatches } from './rules'
4import type { ProjectConfig } from './rules'
5
6const ALLOW = 'Allow once'
7const DENY = 'Deny'
8const PROJECT_CONFIG_PATH = '.claude/machine-guard.json'
9
10async function readProjectConfig($: EngineInterface): Promise<ProjectConfig> {
11 const path = `${await $.session.cwd()}/${PROJECT_CONFIG_PATH}`
12 if (!(await $.fs.exists(path))) {
13 return { block: [], ask: [] }
14 }
15 try {
16 const parsed = JSON.parse(await $.fs.read(path)) as Partial<ProjectConfig>
17
18 return { block: parsed.block ?? [], ask: parsed.ask ?? [] }
19 } catch {
20 $.ui.log(`machine-guard: ${PROJECT_CONFIG_PATH} is not valid JSON and is ignored.`)
21
22 return { block: [], ask: [] }
23 }
24}
25
26export const register: Register = on => {
27 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
28 const config = await readProjectConfig($)
29
30 const blocked = findProjectMatches(e.command, config.block)[0]
31 if (blocked !== undefined) {
32 return { deny: `Blocked in this project by ${PROJECT_CONFIG_PATH}: ${blocked.reason}` }
33 }
34
35 const machineReasons = [...new Set(findMachineChanges(e.command).map(i => i.reason))]
36 const projectReasons = [...new Set(findProjectMatches(e.command, config.ask).map(i => i.reason))]
37 if (machineReasons.length === 0 && projectReasons.length === 0) {
38 return next(e)
39 }
40
41 const commandPreview = e.command.length > 300 ? `${e.command.slice(0, 300)}…` : e.command
42 const intro = machineReasons.length > 0
43 ? `Claude wants to run a command that changes this machine (${machineReasons.join('; ')}):`
44 : 'Claude wants to run a command this project asks about:'
45 const projectNote = projectReasons.length > 0 ? `\n\nProject: ${projectReasons.join('; ')}` : ''
46
47 // The dialog goes to the person directly: answering `ask` from tool.check
48 // instead would hand the decision to auto mode, which may approve it alone.
49 let answer: string
50 try {
51 answer = await $.ui.ask(`${intro}\n\n${commandPreview}${projectNote}\n\nAllow it?`, {
52 options: [ALLOW, DENY],
53 header: 'Machine',
54 })
55 } catch {
56 answer = DENY
57 }
58
59 if (answer === ALLOW) {
60 return next(e)
61 }
62
63 const reasons = [...machineReasons, ...projectReasons].join('; ')
64 const note = answer === DENY ? '' : ` The user answered: "${answer}".`
65
66 return {
67 deny: `The user did not approve this command (${reasons}).${note} Do not run it another way; give the user the command in a code block instead and wait.`,
68 }
69 }).catch(($, e, next) => {
70 // A failing guard must not wave the command through. Once the hook had
71 // passed the command on, it was approved or harmless and keeps that result.
72 if (next.called) {
73 return undefined
74 }
75
76 return {
77 deny: `machine-guard could not check this command (${next.error.message ?? next.error.kind}), so it was not run. Give the user the command in a code block instead.`,
78 }
79 })
80}
81hooks/rules.ts 315 lines1export type Finding = { segment: string; reason: string }
2
3type Rule = (tool: string, args: readonly string[], segment: string) => string | null
4
5const WRAPPERS = new Set(['command', 'exec', 'time', 'nohup', 'env'])
6
7const GLOBAL_FLAGS = new Set(['-g', '--global'])
8
9function has(args: readonly string[], ...values: string[]): boolean {
10 return args.some(i => values.includes(i))
11}
12
13function subcommand(args: readonly string[]): string | undefined {
14 return args.find(i => !i.startsWith('-'))
15}
16
17function isVirtualEnvironment(executable: string): boolean {
18 return /(^|\/)\.?venv\//.test(executable)
19}
20
21const RULES: Rule[] = [
22 tool => (tool === 'sudo' ? 'runs with root privileges' : null),
23
24 (tool, args) => {
25 if (tool !== 'brew') {
26 return null
27 }
28 const verb = subcommand(args)
29 const changing = ['install', 'reinstall', 'upgrade', 'uninstall', 'remove', 'rm', 'tap', 'untap', 'link',
30 'unlink', 'cleanup', 'autoremove', 'migrate', 'update', 'pin', 'unpin']
31 if (verb !== undefined && changing.includes(verb)) {
32 return `brew ${verb} changes installed packages`
33 }
34 if (verb === 'bundle' && !has(args, 'dump', 'check', 'list')) {
35 return 'brew bundle installs packages'
36 }
37
38 return null
39 },
40
41 (tool, args) => {
42 if (!['npm', 'pnpm', 'yarn', 'bun'].includes(tool)) {
43 return null
44 }
45 const isGlobal = args.some(i => GLOBAL_FLAGS.has(i)) || (tool === 'yarn' && args[0] === 'global')
46 const verb = subcommand(tool === 'yarn' && args[0] === 'global' ? args.slice(1) : args)
47 const changing = ['i', 'install', 'add', 'update', 'upgrade', 'uninstall', 'remove', 'rm', 'un', 'link']
48
49 return isGlobal && verb !== undefined && changing.includes(verb) ? `${tool} ${verb} changes global packages` : null
50 },
51
52 (tool, args, segment) => {
53 const executable = segment.trim().split(/\s+/)[0] ?? ''
54 const isPip = tool === 'pip' || tool === 'pip3'
55 const isPythonModule = /^python(3(\.\d+)?)?$/.test(tool) && args[0] === '-m' && args[1]?.startsWith('pip')
56 if (!isPip && !isPythonModule) {
57 return null
58 }
59 const pipArgs = isPythonModule ? args.slice(2) : args
60 if (!has(pipArgs, 'install', 'uninstall') || isVirtualEnvironment(executable)) {
61 return null
62 }
63
64 return 'pip changes packages outside a virtual environment'
65 },
66
67 (tool, args) => {
68 const verb = subcommand(args)
69 switch (tool) {
70 case 'pipx':
71 case 'gem':
72 case 'cargo':
73 case 'go':
74 return verb !== undefined && ['install', 'uninstall', 'upgrade', 'inject', 'ensurepath'].includes(verb)
75 ? `${tool} ${verb} changes installed tools`
76 : null
77 case 'uv':
78 return (verb === 'tool' || verb === 'python') && has(args, 'install', 'uninstall', 'upgrade')
79 ? `uv ${verb} changes installed tools`
80 : null
81 case 'dotnet':
82 return (verb === 'tool' && has(args, 'install', 'update', 'uninstall') && args.some(i => GLOBAL_FLAGS.has(i)))
83 || (verb === 'workload' && has(args, 'install', 'update', 'uninstall'))
84 ? `dotnet ${verb} changes installed tools`
85 : null
86 case 'mise':
87 case 'asdf':
88 case 'nvm':
89 case 'rustup':
90 return verb !== undefined && ['install', 'use', 'upgrade', 'uninstall', 'prune', 'update', 'plugins', 'plugin',
91 'default', 'toolchain', 'global'].includes(verb)
92 ? `${tool} ${verb} changes installed runtimes or their configuration`
93 : null
94 case 'winget':
95 case 'choco':
96 case 'scoop':
97 return verb !== undefined && ['install', 'uninstall', 'upgrade', 'update', 'remove'].includes(verb)
98 ? `${tool} ${verb} changes installed packages`
99 : null
100 default:
101 return null
102 }
103 },
104
105 (tool, args) => {
106 const verb = subcommand(args)
107 switch (tool) {
108 // Starting and stopping services is left to service-radar, which tracks
109 // what was started; only creating, deleting and downloading stays here.
110 case 'colima':
111 case 'limactl':
112 return verb === 'delete' ? `${tool} delete removes a VM and its data` : null
113 case 'podman':
114 return verb === 'machine' && has(args, 'init', 'rm') ? 'podman machine creates or removes a VM' : null
115 case 'docker': {
116 if (verb === 'compose') {
117 const composeVerb = subcommand(args.slice(args.indexOf('compose') + 1))
118 return composeVerb !== undefined && ['pull', 'build', 'create'].includes(composeVerb)
119 ? `docker compose ${composeVerb} downloads or builds images`
120 : null
121 }
122 return verb !== undefined && ['pull', 'build', 'create', 'load', 'import'].includes(verb)
123 ? `docker ${verb} downloads or builds images`
124 : null
125 }
126 case 'launchctl':
127 return verb !== undefined && ['enable', 'submit'].includes(verb)
128 ? `launchctl ${verb} changes background services permanently`
129 : null
130 default:
131 return null
132 }
133 },
134
135 (tool, args) => {
136 switch (tool) {
137 case 'softwareupdate':
138 return has(args, '-i', '--install', '-a', '--all', '--install-rosetta') ? 'softwareupdate installs system software' : null
139 case 'xcode-select':
140 return has(args, '--install', '-s', '--switch', '-r', '--reset') ? 'xcode-select changes the developer tools' : null
141 case 'defaults':
142 return has(args, 'write', 'delete') ? 'defaults changes system or app preferences' : null
143 case 'chsh':
144 return 'chsh changes the login shell'
145 case 'git': {
146 const index = args.indexOf('config')
147 if (index === -1 || !has(args, '--global', '--system')) {
148 return null
149 }
150 const isRead = has(args, '--get', '--get-all', '--get-regexp', '--list', '-l', '--show-origin')
151 const valueArgs = args.slice(index + 1).filter(i => !i.startsWith('-'))
152
153 return !isRead && valueArgs.length >= 2 ? 'git config changes the global git configuration' : null
154 }
155 default:
156 return null
157 }
158 },
159]
160
161const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'fish', 'ssh', 'sudo', 'xargs', 'eval', 'source', '.'])
162
163// A heredoc body is data for the program it is fed to; only a shell turns it
164// back into commands. Bodies fed to anything else (python3, cat, tee) are left
165// out of the check, so writing documentation that mentions commands is not
166// mistaken for running them.
167export function stripHeredocs(command: string): string {
168 const lines = command.split('\n')
169 const kept: string[] = []
170 let index = 0
171 while (index < lines.length) {
172 const line = lines[index] ?? ''
173 kept.push(line)
174 index += 1
175 const marker = /<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(line)
176 if (marker === null) {
177 continue
178 }
179 const segment = line.slice(0, marker.index).split(/&&|\|\||[;|]/).pop() ?? ''
180 const program = segment.trim().split(/\s+/).find(i => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(i)) ?? ''
181 const pipesIntoShell = /\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(line.slice(marker.index))
182 if (SHELLS.has(program.split('/').pop() ?? '') || pipesIntoShell) {
183 continue
184 }
185 const delimiter = marker[3]
186 const stripsTabs = marker[1] === '-'
187 while (index < lines.length) {
188 const body = lines[index] ?? ''
189 index += 1
190 if ((stripsTabs ? body.replace(/^\t+/, '') : body) === delimiter) {
191 kept.push(body)
192 break
193 }
194 }
195 }
196
197 return kept.join('\n')
198}
199
200type Segment = { text: string; isPiped: boolean }
201
202// A command like `cd app && brew install jq` is judged per segment, so a
203// harmless first part never hides the changing one. Separators inside quotes
204// are data, as in `grep 'brew install\|npm i -g'`; only `$(` and backticks
205// still open a command inside double quotes.
206function scanSegments(command: string): Segment[] {
207 const segments: Segment[] = []
208 let current = ''
209 let isPiped = false
210 let quote: '\'' | '"' | null = null
211 const close = (nextIsPiped: boolean) => {
212 if (current.trim().length > 0) {
213 segments.push({ text: current.trim(), isPiped })
214 }
215 current = ''
216 isPiped = nextIsPiped
217 }
218 let index = 0
219 while (index < command.length) {
220 const char = command[index] ?? ''
221 const pair = command.slice(index, index + 2)
222 if (quote === '\'') {
223 current += char
224 quote = char === '\'' ? null : quote
225 index += 1
226 } else if (char === '\\') {
227 current += pair
228 index += 2
229 } else if (pair === '$(' || char === '`') {
230 close(false)
231 index += char === '`' ? 1 : 2
232 } else if (quote === '"') {
233 current += char
234 quote = char === '"' ? null : quote
235 index += 1
236 } else if (char === '\'' || char === '"') {
237 current += char
238 quote = char
239 index += 1
240 } else if (pair === '&&' || pair === '||') {
241 close(false)
242 index += 2
243 } else if (char === '|') {
244 close(true)
245 index += 1
246 } else if (char === ';' || char === '\n') {
247 close(false)
248 index += 1
249 } else {
250 current += char
251 index += 1
252 }
253 }
254 close(false)
255
256 return segments
257}
258
259function splitSegments(command: string): string[] {
260 return scanSegments(command).map(i => i.text)
261}
262
263function tokenize(segment: string): string[] {
264 const tokens = segment.split(/\s+/).map(i => i.replace(/^['"]|['"]$/g, ''))
265 let start = 0
266 while (start < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[start] ?? '') || WRAPPERS.has(tokens[start] ?? ''))) {
267 start += 1
268 }
269
270 return tokens.slice(start)
271}
272
273export function findMachineChanges(fullCommand: string): Finding[] {
274 const findings: Finding[] = []
275 const command = stripHeredocs(fullCommand)
276
277 const segments = scanSegments(command)
278 segments.forEach((segment, index) => {
279 const [downloader] = tokenize(segment.text)
280 const next = segments[index + 1]
281 const [shell, ...shellArgs] = next?.isPiped === true ? tokenize(next.text) : []
282 const runner = shell === 'sudo' ? shellArgs[0] : shell
283 if ((downloader === 'curl' || downloader === 'wget') && runner !== undefined && /^(ba|z|da)?sh$/.test(runner)) {
284 findings.push({ segment: command.trim(), reason: 'pipes a downloaded script into a shell' })
285 }
286 })
287
288 for (const { text: segment } of segments) {
289 const [first, ...args] = tokenize(segment)
290 if (first === undefined) {
291 continue
292 }
293 const tool = first.split('/').pop() ?? first
294 for (const rule of RULES) {
295 const reason = rule(tool, args, segment)
296 if (reason !== null) {
297 findings.push({ segment, reason })
298 break
299 }
300 }
301 }
302
303 return findings
304}
305
306export type ProjectRule = { match: string; reason: string }
307
308export type ProjectConfig = { block: ProjectRule[]; ask: ProjectRule[] }
309
310export function findProjectMatches(command: string, rules: readonly ProjectRule[]): ProjectRule[] {
311 const segments = splitSegments(command)
312
313 return rules.filter(rule => segments.some(segment => new RegExp(rule.match).test(segment)))
314}
315