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

Managed using GNU Stow
Structure: "name-of-package"/path-to/destination stow "package-to-install" to set up symlinks stow -D "package-to-uninstall" to remove symlinks
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.
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.
hooks/register.ts 289 lines1import 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