SLOPSHOPPER

machine-guard

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

newguard
★ 2v0.1.0MITupdated 2026-10-04MichaelP17/claude-mods/machine-guard
A shopper browsing a rack in a slop shop
README

machine-guard

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.

What is caught

CaughtLet through
sudo, `curl … \sh`read-only commands such as brew list, docker ps
brew install, upgrade, uninstall, tap, bundleproject dependencies: npm install, npm ci, pnpm install
global npm, pnpm, yarn, bun installspip inside a virtual environment (.venv/bin/pip)
pip outside a virtual environment, pipx, uv tool, cargo install, go install, gem install, dotnet tool install -gstarting and stopping services: colima start, docker compose up, docker run, brew services start, launchctl load
mise install and use, asdf, rustupgit 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.

Per-project rules

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" }
  ]
}
LevelEffect
built inthe dialog for the commands in the table above, in every project
askthe dialog with reason shown, also for commands the built-in rules let through
blockrefused without a dialog; Claude receives reason

A command caught by a built-in rule and an ask rule shows one dialog with both reasons.

Limits

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.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Project files .claude/machine-guard.json are ignored without it.

Source 2 files
hooks/register.ts 81 lines
1import 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}
81
hooks/rules.ts 315 lines
1export 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