SLOPSHOPPER

shell-guard

Denies Bash writes the command guard cannot resolve or that bypass Edit and Write

newguard
v0.1.0no licenseupdated 2026-10-05pontusc/.dotfiles/claude/.claude/skills/shell-guard
A shopper browsing a rack in a slop shop
README

Dotfiles

Managed using GNU Stow

Reminders

Structure: "name-of-package"/path-to/destination stow "package-to-install" to set up symlinks stow -D "package-to-uninstall" to remove symlinks

OMP on Omarchy

Install both packages with stow omp omarchy, then apply an Omarchy theme to generate the OMP palette. Restart OMP once after setup so it starts with the custom theme's file watcher. Subsequent Omarchy theme switches update OMP live. Changing the theme through Appearance settings alone does not start the watcher in the current OMP runtime.

Vivaldi on Omarchy

Install the packages and browser UI modification from the repository root as your desktop user:

make install-vivaldi

The target runs Stow, prompts for sudo to patch Vivaldi's system resources, and reapplies the current Omarchy theme. Stow alone is not sufficient. Restart Vivaldi once after it completes. Subsequent theme switches update its interface within about a second, without a debugging port or local server. The integration selects its own Omarchy theme and disables Vivaldi's theme scheduling.

Run make install-vivaldi again after Vivaldi updates, then restart the browser. The modification uses Vivaldi's internal API and may need changes when that API changes. The installation supports one desktop user and shares that user's palette with every Vivaldi profile using this installation.

To stop syncing, remove the omarchy-theme.js script tag from /opt/vivaldi/resources/vivaldi/window.html, restart Vivaldi, and select your preferred theme and scheduling settings.

Required tools

  • stow
  • make
  • kitty
  • tmux and TPM
  • starship
  • gh, with extensions dlvhdr/gh-dash and dlvhdr/gh-enhance
  • lazygit
  • lazydocker
  • fzf
  • ripgrep
  • zoxide
  • eza
  • uv
  • stylua
  • dcg, from github.com/Dicklesworthstone/destructive_command_guard, and jq
  • shellcheck and lua-language-server, installed by nvim through Mason
Source 1 files
hooks/register.ts 289 lines
1import type { Register } from 'claude-code'
2
3const COMMAND_START = String.raw`(?:^|[;&|(]|\b(?:do|then|else|sudo|xargs))\s*`
4const VARIABLE_PATH = String.raw`([^\s;&|<>]*\$\{?[A-Za-z_][^\s;&|<>]*)`
5const RECURSIVE_FLAG = String.raw`\s(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)\b`
6
7const REDIRECT_TARGET = new RegExp(String.raw`(?:^|[^<>&\d=-])[\d&]?>(?!>)\|?\s*${VARIABLE_PATH}`, 'gm')
8const RECURSIVE_REMOVE_TARGET = new RegExp(String.raw`${COMMAND_START}rm\b(?=[^;&|\n]*${RECURSIVE_FLAG})[^;&|\n]*\s${VARIABLE_PATH}`, 'gm')
9const VARIABLE_NAME = /\$\{?([A-Za-z_]\w*)/g
10const ASSIGNMENT = /(?:^|[;&|(\s])(?:(?:export|local|readonly)\s+)?([A-Za-z_]\w*)=/gm
11const LOOP = /\bfor\s+([A-Za-z_]\w*)\s+in\s+([^;\n]*)/g
12const TEST_EXPRESSION = /\[\[[^\]\n]*\]\]/g
13
14const HEREDOC_OPENER = /<<(-?)\s*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([^\s;&|<>()'"]+))/y
15const QUOTED_SPECIAL = { "'": /[\s<>;&|$]/g, '"': /[\s<>;&|]/g } as const
16const FILE_REDIRECT_TARGET = /(?<![<>\d&])[1&]?>>?\|?\s*([^\s;&|<>(][^\s;&|<>]*)/g
17const TEE_TARGET = /\btee\s+(?:-\S+\s+)*([^\s;&|<>]+)/g
18const REMOTE_OR_PRIVILEGED = /\b(?:sudo|ssh|kubectl|docker|podman)\b/
19const PYTHON_INTERPRETER = /(?:^|[\s(])python3?\s/
20const PYTHON_WRITE = /\bopen\((?:[^()]|\([^()]*\))*?,\s*(?:mode\s*=\s*)?['"][rwaxbt+]*[wax+][rwaxbt+]*['"]\s*[,)]|\.write_(?:text|bytes)\(/
21const PYTHON_LITERAL_READ = /\bopen\(\s*(['"])[^'"\n]*\1\s*(?:,\s*(?:mode\s*=\s*)?['"][rbt]*['"]\s*)?\)|\bPath\(\s*(['"])[^'"\n]*\2\s*\)\.read_(?:text|bytes)\(/g
22const SCOPE_CHANGE = /(?:^|[;&|(\s])(?:export\s+)?([A-Za-z_]\w*)=([^\s;&|)]*)|\b(cd|pushd)\s+(?:-\S*\s+)*([^\s;&|)]+)|\b(popd)\b/gm
23const INNERMOST_GROUP = /\$?\([^()]*\)/g
24const VARIABLE_REFERENCE = /\$\{?([A-Za-z_]\w*)\}?/g
25
26type Quote = keyof typeof QUOTED_SPECIAL
27
28type Heredoc = {
29  opener: string
30  line: string
31  before: string
32  body: string
33}
34
35type PendingHeredoc = {
36  opener: string
37  delimiter: string
38  stripTabs: boolean
39}
40
41type Script = {
42  shell: string
43  heredocs: readonly Heredoc[]
44}
45
46type Scope = {
47  values: ReadonlyMap<string, string>
48  cwd: string | null
49}
50
51function isScratch(path: string): boolean {
52  return path.startsWith('/tmp/') || path.startsWith('/dev/')
53}
54
55function sanitize(text: string, quote: Quote): string {
56  return text.replace(QUOTED_SPECIAL[quote], '_')
57}
58
59function isWordStart(command: string, index: number): boolean {
60  return index === 0 || /[\s;&|()]/.test(command[index - 1] ?? '')
61}
62
63function readBody(command: string, start: number, heredoc: PendingHeredoc): { body: string; next: number } {
64  let lineStart = start
65  while (lineStart < command.length) {
66    const newline = command.indexOf('\n', lineStart)
67    const lineEnd = newline === -1 ? command.length : newline
68    const text = command.slice(lineStart, lineEnd)
69    if ((heredoc.stripTabs ? text.replace(/^\t+/, '') : text) === heredoc.delimiter) {
70      return { body: command.slice(start, lineStart), next: lineEnd + 1 }
71    }
72    lineStart = lineEnd + 1
73  }
74  return { body: command.slice(start), next: command.length }
75}
76
77function arithmeticEnd(command: string, start: number): number | null {
78  let depth = 0
79  for (let index = start; index < command.length; index += 1) {
80    if (command[index] === '(') depth += 1
81    else if (command[index] === ')') {
82      if (depth > 0) depth -= 1
83      else return command[index + 1] === ')' ? index + 2 : null
84    }
85  }
86  return null
87}
88
89function parseScript(command: string): Script {
90  const heredocs: Heredoc[] = []
91  const pending: PendingHeredoc[] = []
92  const frames: ('double' | number)[] = []
93  let quoted = ''
94  let shell = ''
95  let lineStart = 0
96  let index = 0
97
98  while (index < command.length) {
99    const char = command[index] ?? ''
100    const frame = frames.at(-1)
101
102    if (frame === 'double') {
103      if (char === '\\') {
104        quoted += command.slice(index, index + 2)
105        index += 2
106      } else if (char === '"') {
107        shell += sanitize(quoted, '"')
108        quoted = ''
109        frames.pop()
110        index += 1
111      } else if (command.startsWith('$((', index) && arithmeticEnd(command, index + 3) !== null) {
112        const end = arithmeticEnd(command, index + 3) ?? command.length
113        quoted += command.slice(index, end)
114        index = end
115      } else if (command.startsWith('$(', index)) {
116        shell += `${sanitize(quoted, '"')}$(`
117        quoted = ''
118        frames.push(1)
119        index += 2
120      } else {
121        quoted += char
122        index += 1
123      }
124      continue
125    }
126
127    if (char === '\\') {
128      shell += command[index + 1] === '\n' ? ' ' : command.slice(index, index + 2)
129      index += 2
130      continue
131    }
132    if (char === "'") {
133      const close = command.indexOf("'", index + 1)
134      const end = close === -1 ? command.length : close
135      shell += sanitize(command.slice(index + 1, end), "'")
136      index = end + 1
137      continue
138    }
139    if (char === '"') {
140      frames.push('double')
141      index += 1
142      continue
143    }
144    const arithmetic = command.startsWith('((', index) ? arithmeticEnd(command, index + 2) : null
145    if (arithmetic !== null) {
146      shell += sanitize(command.slice(index, arithmetic), '"')
147      index = arithmetic
148      continue
149    }
150    if (char === '#' && isWordStart(command, index)) {
151      const newline = command.indexOf('\n', index)
152      index = newline === -1 ? command.length : newline
153      continue
154    }
155    if (command.startsWith('<<', index) && command[index + 2] !== '<' && command[index - 1] !== '<') {
156      HEREDOC_OPENER.lastIndex = index
157      const match = HEREDOC_OPENER.exec(command)
158      if (match !== null) {
159        pending.push({ opener: match[0], delimiter: match[2] ?? match[3] ?? match[4] ?? '', stripTabs: match[1] === '-' })
160        shell += match[0]
161        index += match[0].length
162        continue
163      }
164    }
165    if (typeof frame === 'number' && char === '(') frames[frames.length - 1] = frame + 1
166    if (typeof frame === 'number' && char === ')') {
167      if (frame === 1) frames.pop()
168      else frames[frames.length - 1] = frame - 1
169    }
170    if (char === '\n' && pending.length > 0) {
171      const line = shell.slice(lineStart)
172      const before = shell.slice(0, lineStart)
173      let bodyStart = index + 1
174      for (const heredoc of pending) {
175        const { body, next } = readBody(command, bodyStart, heredoc)
176        heredocs.push({ opener: heredoc.opener, line, before, body })
177        bodyStart = next
178      }
179      pending.length = 0
180      shell += '\n'
181      lineStart = shell.length
182      index = bodyStart
183      continue
184    }
185    shell += char
186    if (char === '\n') lineStart = shell.length
187    index += 1
188  }
189
190  shell += sanitize(quoted, '"')
191  for (const heredoc of pending) {
192    heredocs.push({ opener: heredoc.opener, line: shell.slice(lineStart), before: shell.slice(0, lineStart), body: '' })
193  }
194  return { shell, heredocs }
195}
196
197function hasUnresolvedTarget(shell: string): boolean {
198  const statements = shell.replace(TEST_EXPRESSION, '_')
199  const assigned = new Set([...statements.matchAll(ASSIGNMENT)].map(match => match[1] ?? ''))
200  const literalLoopNames = new Set(
201    [...statements.matchAll(LOOP)].filter(match => !/[*?$`(\[]/.test(match[2] ?? '')).map(match => match[1] ?? ''),
202  )
203  const isRelative = (target: string): boolean => {
204    const leading = /^\$\{?([A-Za-z_]\w*)/.exec(target)?.[1]
205    return !target.startsWith('/') && (leading === undefined || !assigned.has(leading))
206  }
207  const unresolved = (target: string, loopResolves: boolean): boolean =>
208    [...target.matchAll(VARIABLE_NAME)].some(([, name = '']) =>
209      !assigned.has(name) && !(loopResolves && literalLoopNames.has(name) && isRelative(target)),
210    )
211  return (
212    [...statements.matchAll(REDIRECT_TARGET)].some(([, target = '']) => unresolved(target, true)) ||
213    [...statements.matchAll(RECURSIVE_REMOVE_TARGET)].some(([, target = '']) => unresolved(target, false))
214  )
215}
216
217function expand(word: string, values: ReadonlyMap<string, string>): string {
218  return word.replace(VARIABLE_REFERENCE, (reference, name: string) => values.get(name) ?? reference)
219}
220
221function withoutGroups(text: string): string {
222  const collapsed = text.replace(INNERMOST_GROUP, group => (group.startsWith('$') ? '$_' : '_'))
223  return collapsed === text ? text : withoutGroups(collapsed)
224}
225
226function scopeOf(text: string): Scope {
227  const values = new Map<string, string>()
228  const pushed: (string | null)[] = []
229  let cwd: string | null = '.'
230  for (const [, name, value, verb, directory, popd] of withoutGroups(text).matchAll(SCOPE_CHANGE)) {
231    if (popd !== undefined) {
232      cwd = pushed.length > 0 ? (pushed.pop() ?? null) : null
233      continue
234    }
235    if (verb === 'pushd') pushed.push(cwd)
236    if (name !== undefined) {
237      const resolved = expand(value ?? '', values)
238      if (/[$`]/.test(resolved)) values.delete(name)
239      else values.set(name, resolved)
240      continue
241    }
242    const resolved = expand(directory ?? '', values)
243    if (resolved.includes('$') || resolved === '-') cwd = null
244    else if (/^[/~]/.test(resolved)) cwd = resolved
245    else if (cwd !== null) cwd = `${cwd}/${resolved}`
246  }
247  return { values, cwd }
248}
249
250function writesOutsideScratch(heredoc: Heredoc): boolean {
251  const segment = heredoc.line.split(/&&|\|\||;/).find(part => part.includes(heredoc.opener)) ?? ''
252  const pipeline = segment.split(/(?<!>)\|/)
253  const producerIndex = pipeline.findIndex(part => part.includes(heredoc.opener))
254  const producer = pipeline[producerIndex] ?? ''
255  const { values, cwd } = scopeOf(heredoc.before + heredoc.line.slice(0, heredoc.line.indexOf(heredoc.opener)))
256  if (PYTHON_INTERPRETER.test(producer)) {
257    const touchesScratch = /\/(?:tmp|dev)\//.test(heredoc.body) || expand(producer, values).includes('/tmp/')
258    const namesOutsidePath = /['"](?:\/(?!tmp\/|dev\/)|~\/)/.test(heredoc.body.replace(PYTHON_LITERAL_READ, '_'))
259    const writesRelativeOutside = cwd !== null && !isScratch(`${cwd}/`)
260    return PYTHON_WRITE.test(heredoc.body) && !touchesScratch && (namesOutsidePath || writesRelativeOutside)
261  }
262  if (!/\b(?:cat|tee)\b/.test(producer)) return false
263
264  const teeTargets = (part: string): RegExpMatchArray[] => REMOTE_OR_PRIVILEGED.test(part) ? [] : [...part.matchAll(TEE_TARGET)]
265  const targets = [
266    ...producer.matchAll(FILE_REDIRECT_TARGET),
267    ...teeTargets(producer),
268    ...pipeline.slice(producerIndex + 1).flatMap(teeTargets),
269  ].map(match => expand(match[1] ?? '', values))
270  return targets.some(target => {
271    if (target.includes('$')) return false
272    if (/^[/~]/.test(target)) return !isScratch(target)
273    return cwd !== null && !isScratch(`${cwd}/${target}`)
274  })
275}
276
277export const register: Register = on => {
278  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
279    const script = parseScript(e.command)
280    if (hasUnresolvedTarget(script.shell)) {
281      return { deny: `${$.plugin.name}: a truncating redirect or recursive rm target holds a shell variable this command never assigns, which dcg cannot resolve and blocks. Spell out the literal absolute path.` }
282    }
283    if (script.heredocs.some(writesOutsideScratch)) {
284      return { deny: `${$.plugin.name}: a heredoc writes a file outside /tmp. Use Edit or Write, they read before writing and run the PostToolUse checks.` }
285    }
286    return next(e)
287  })
288}
289