oh-my-customcode 1.1.93 → 1.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,60 +1,905 @@
1
1
  #!/bin/bash
2
- # rule-deletion-guard.sh — Block rule file deletion without individual user confirmation
3
- # Requires: jq
2
+ # rule-deletion-guard.sh — Block rule file deletion without individual user confirmation (#1782)
3
+ #
4
+ # Path-aware (python3): a quote-aware shell lexer resolves rm/mv/unlink/rmdir/shred/truncate,
5
+ # find -delete/-exec, xargs <destructive>, git rm|mv|clean and writing redirects (> >| >> &> &>> >&)
6
+ # against the PROJECT rule dirs (<root>/.claude/rules and <root>/templates/.claude/rules).
7
+ # Inside a rule dir -> block. Ancestor of a rule dir -> block only for recursive delete/move.
8
+ # Structural fail-closed: anything the analyzer does not positively model (unknown wrapper before a
9
+ # destructive verb, destructive verb in a non-head position, unresolvable expansion in a target,
10
+ # opaque constructs such as eval / sh -c / $(...) / backticks / source, parse errors, unknown cwd,
11
+ # analyzer exceptions) -> strict legacy regex check (blocks if the pre-#1782 logic would block).
12
+ # No python3: legacy regex check (needs jq). Neither python3 nor jq: fail-open (exit 0).
4
13
 
5
14
  set -uo pipefail
6
15
 
7
16
  input=$(cat)
8
17
 
9
- # Dependency check — allow if jq missing
10
- if ! command -v jq &>/dev/null; then
11
- printf '%s\n' "$input"
12
- exit 0
13
- fi
18
+ # Legacy regex logic (verbatim semantics of the pre-#1782 guard). Arg: command string. Exits 2 on block.
19
+ # Arg 2 = "strict": used after python fallbacks; the verb may also follow a quote/paren/slash
20
+ # (e.g. bash -c 'rm ...', /bin/rm) so the fallback is never weaker than the analyzer's intent.
21
+ legacy_check() {
22
+ local cmd="$1" targets target_count filename
23
+ local pre='(^|\s)'
24
+ [ "${2:-}" = "strict" ] && pre='(^|[^[:alnum:]_-])'
14
25
 
15
- # Parse tool input
16
- tool=$(printf '%s\n' "$input" | jq -r '.tool_name // .tool // ""' 2>/dev/null) || { printf '%s\n' "$input"; exit 0; }
17
- cmd=$(printf '%s\n' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || { printf '%s\n' "$input"; exit 0; }
26
+ # Check if command would delete parent directories containing rules
27
+ if echo "$cmd" | grep -qE "${pre}(rm|git\s+rm|mv|unlink)\s" && echo "$cmd" | grep -qE '\.claude/?(\s|$)'; then
28
+ echo "[Hook] ⛔ RULE DELETION BLOCKED — Parent directory deletion detected" >&2
29
+ echo "[Hook] This command combines a delete/move verb with a .claude/ directory reference (pattern check)." >&2
30
+ echo "[Hook] Delete rules individually with user confirmation." >&2
31
+ echo "[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지)." >&2
32
+ exit 2
33
+ fi
18
34
 
19
- # Only check Bash tool
20
- if [ "$tool" != "Bash" ]; then
21
- printf '%s\n' "$input"
22
- exit 0
23
- fi
35
+ # Check if command targets .claude/rules/ for deletion (including mv, unlink)
36
+ if echo "$cmd" | grep -qE "${pre}(rm|git\s+rm|mv|unlink)\s" && echo "$cmd" | grep -qE '\.claude/rules(/|\s|$)'; then
37
+ targets=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | tr '\n' ', ' | sed 's/,$//')
38
+ target_count=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | wc -l | tr -d ' ')
24
39
 
25
- # Check if command would delete parent directories containing rules
26
- if echo "$cmd" | grep -qE '(^|\s)(rm|git\s+rm|mv|unlink)\s' && echo "$cmd" | grep -qE '\.claude/?(\s|$)'; then
27
- echo "[Hook] ⛔ RULE DELETION BLOCKED — Parent directory deletion detected" >&2
28
- echo "[Hook] This command would delete the entire .claude/ directory including all rules." >&2
29
- echo "[Hook] Delete rules individually with user confirmation." >&2
30
- exit 2
31
- fi
40
+ if echo "$cmd" | grep -qE '\.claude/rules/\*|\.claude/rules/[^ ]*\*' || [ "$target_count" -gt 1 ]; then
41
+ echo "[Hook] ⛔ RULE DELETION BLOCKED — Multiple rules detected" >&2
42
+ echo "[Hook] Targets: $targets" >&2
43
+ echo "[Hook] Rule files must be deleted ONE AT A TIME with user confirmation." >&2
44
+ echo "[Hook] Delete each rule individually after asking: \"정말 {파일명}을(를) 삭제하시겠습니까?\"" >&2
45
+ echo "[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지)." >&2
46
+ exit 2
47
+ fi
32
48
 
33
- # Check if command targets .claude/rules/ for deletion (including mv, unlink)
34
- if echo "$cmd" | grep -qE '(^|\s)(rm|git\s+rm|mv|unlink)\s' && echo "$cmd" | grep -qE '\.claude/rules(/|\s|$)'; then
35
- # Extract target files
36
- targets=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | tr '\n' ', ' | sed 's/,$//')
37
- target_count=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | wc -l | tr -d ' ')
38
-
39
- # Check for glob/wildcard patterns or multiple targets
40
- if echo "$cmd" | grep -qE '\.claude/rules/\*|\.claude/rules/[^ ]*\*' || [ "$target_count" -gt 1 ]; then
41
- echo "[Hook] ⛔ RULE DELETION BLOCKED — Multiple rules detected" >&2
42
- echo "[Hook] Targets: $targets" >&2
43
- echo "[Hook] Rule files must be deleted ONE AT A TIME with user confirmation." >&2
44
- echo "[Hook] Delete each rule individually after asking: \"정말 {파일명}을(를) 삭제하시겠습니까?\"" >&2
49
+ filename=$(basename "$targets" 2>/dev/null || echo "$targets")
50
+ echo "[Hook] ⛔ RULE DELETION BLOCKED" >&2
51
+ echo "[Hook] Target: $filename" >&2
52
+ echo "[Hook] Rule files require individual user confirmation before deletion." >&2
53
+ echo "[Hook] Ask the user: \"정말 ${filename}을(를) 삭제하시겠습니까?\"" >&2
54
+ echo "[Hook] Only proceed after explicit user approval." >&2
55
+ echo "[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지)." >&2
45
56
  exit 2
46
57
  fi
58
+ }
59
+
60
+ pass() { printf '%s\n' "$input"; exit 0; }
61
+
62
+ # Analyzer (script on fd 3; stdin carries the hook JSON, so the payload size is not bound by ARG_MAX).
63
+ run_analyzer() {
64
+ python3 /dev/fd/3 3<<'PY'
65
+ import fnmatch
66
+ import glob as globmod
67
+ import json
68
+ import os
69
+ import re
70
+ import sys
71
+
72
+ # Exit codes: 0 allow, 2 block (message on stderr), 3 fall back to legacy (stdout = command)
73
+
74
+
75
+ class Fallback(Exception):
76
+ pass
77
+
78
+
79
+ class Word(object):
80
+ __slots__ = ('v', 'exp', 'tilde', 'brace', 'raw')
81
+
82
+ def __init__(self, v, exp, tilde, brace, raw):
83
+ self.v, self.exp, self.tilde, self.brace, self.raw = v, exp, tilde, brace, raw
84
+
85
+
86
+ OPAQUE = re.compile(
87
+ r'(?<![\w-])eval(?![\w-])|\$\(|`'
88
+ r'|(?<![\w-])(?:ba|z|da|k)?sh\b[^\n;|&]*?\s-\w*c\b'
89
+ r'|(?<![\w-])(?:ba|z|da|k)?sh\b[^\n;|&]*<<'
90
+ r'|(?<![\w-])source(?![\w-])'
91
+ )
92
+ VAR = re.compile(r'^[A-Za-z_]\w*=')
93
+ GLOBCH = re.compile(r'[*?\[]')
94
+ OPS = ['<<<', '<<-', '&>>', ';;&', '&&', '||', '|&', ';;', ';&', '>>', '>|', '>&', '<&', '<>', '<<', '&>',
95
+ ';', '&', '|', '(', ')', '<', '>']
96
+ SEP = {';', '&', '&&', '||', '|', '|&', ';;', ';&', ';;&', '(', ')'}
97
+ WREDIR = {'>', '>|', '>>', '&>', '&>>', '<>'}
98
+ OREDIR = {'<', '<<<', '<&', '<<', '<<-'}
99
+ DESTR = {'rm', 'unlink', 'rmdir', 'shred', 'truncate'}
100
+ DWORDS = DESTR | {'mv', 'xargs'}
101
+ FINDACT = {'-delete', '-exec', '-execdir', '-ok', '-okdir'}
102
+ FINDFILT = {'-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex'}
103
+ RESERVED = {'if', 'then', 'do', 'else', 'elif', 'while', 'until', '!', '{', '}'}
104
+ # wrapper -> (options taking a separate value, leading positionals before the command)
105
+ WRAP = {
106
+ 'sudo': ({'-u', '-g', '-h', '-p', '-C', '-D', '-R', '-T', '-U', '-r', '-t'}, 0),
107
+ 'doas': ({'-u', '-C'}, 0),
108
+ 'command': (set(), 0), 'builtin': (set(), 0), 'exec': ({'-a'}, 0),
109
+ 'env': ({'-u', '-C', '-S', '-P', '--unset', '--chdir', '--split-string'}, 0),
110
+ 'nice': ({'-n', '--adjustment'}, 0), 'nohup': (set(), 0), 'time': ({'-f', '-o'}, 0),
111
+ 'timeout': ({'-s', '-k', '--signal', '--kill-after'}, 1),
112
+ 'gtimeout': ({'-s', '-k', '--signal', '--kill-after'}, 1),
113
+ 'stdbuf': ({'-i', '-o', '-e', '--input', '--output', '--error'}, 0),
114
+ 'caffeinate': ({'-t', '-w'}, 0), 'busybox': (set(), 0), 'ionice': ({'-c', '-n', '-p', '-t'}, 0),
115
+ 'chronic': (set(), 0), 'nocorrect': (set(), 0), 'noglob': (set(), 0),
116
+ }
117
+ GIT_VAL = {'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env'}
118
+
119
+
120
+ def lex(s):
121
+ """Quote-aware shell tokenizer: [('op', str) | ('w', Word)]. Raises Fallback when unsure."""
122
+ toks, pend = [], []
123
+ n, i = len(s), 0
124
+ st = {'w': None, 'delim': False}
125
+
126
+ def start():
127
+ if st['w'] is None:
128
+ st['w'] = {'v': [], 'exp': False, 'tilde': False, 'ub': False, 'i': i}
129
+ return st['w']
130
+
131
+ def end(j):
132
+ w = st['w']
133
+ if w is None:
134
+ return
135
+ v = ''.join(w['v'])
136
+ brace = w['ub'] and v not in ('{', '}', '{}') and '}' in v and (',' in v or '..' in v)
137
+ st['w'] = None
138
+ if st['delim']:
139
+ pend.append(v)
140
+ st['delim'] = False
141
+ return
142
+ toks.append(('w', Word(v, w['exp'], w['tilde'], brace, s[w['i']:j])))
143
+
144
+ while i < n:
145
+ c = s[i]
146
+ if c == '#' and st['w'] is None:
147
+ j = s.find('\n', i)
148
+ i = n if j < 0 else j
149
+ continue
150
+ if c in ' \t':
151
+ end(i)
152
+ i += 1
153
+ continue
154
+ if c == '\n':
155
+ end(i)
156
+ if st['delim']:
157
+ raise Fallback()
158
+ toks.append(('op', ';'))
159
+ i += 1
160
+ for d in pend:
161
+ while True:
162
+ if i >= n:
163
+ raise Fallback() # unterminated heredoc
164
+ j = s.find('\n', i)
165
+ line = s[i:] if j < 0 else s[i:j]
166
+ i = n if j < 0 else j + 1
167
+ if line.strip() == d:
168
+ break
169
+ del pend[:]
170
+ continue
171
+ if c in ';&|<>()':
172
+ w = st['w']
173
+ if c in '<>' and w is not None and w['v'] and all(ch.isdigit() for ch in w['v']) and s[w['i']:i].isdigit():
174
+ st['w'] = None # fd number before a redirect
175
+ else:
176
+ end(i)
177
+ if c in '<>' and i + 1 < n and s[i + 1] == '(':
178
+ raise Fallback() # process substitution
179
+ op = next(o for o in OPS if s.startswith(o, i))
180
+ if st['delim']:
181
+ raise Fallback()
182
+ toks.append(('op', op))
183
+ if op in ('<<', '<<-'):
184
+ st['delim'] = True
185
+ i += len(op)
186
+ continue
187
+ if c == '\\':
188
+ w = start()
189
+ if i + 1 < n:
190
+ w['v'].append(s[i + 1])
191
+ i += 2
192
+ continue
193
+ if c == "'":
194
+ w = start()
195
+ j = s.find("'", i + 1)
196
+ if j < 0:
197
+ raise Fallback()
198
+ w['v'].append(s[i + 1:j])
199
+ i = j + 1
200
+ continue
201
+ if c == '"' or (c == '$' and s.startswith('$"', i)):
202
+ w = start()
203
+ i += 2 if c == '$' else 1
204
+ while True:
205
+ if i >= n:
206
+ raise Fallback()
207
+ ch = s[i]
208
+ if ch == '"':
209
+ i += 1
210
+ break
211
+ if ch == '\\' and i + 1 < n and s[i + 1] in '$`"\\\n':
212
+ w['v'].append(s[i + 1])
213
+ i += 2
214
+ continue
215
+ if ch in '$`':
216
+ if ch == '`' or s.startswith('$(', i):
217
+ raise Fallback()
218
+ w['exp'] = True
219
+ w['v'].append(ch)
220
+ i += 1
221
+ continue
222
+ if c == '$' and s.startswith("$'", i):
223
+ w = start()
224
+ j, buf = i + 2, []
225
+ while True:
226
+ if j >= n:
227
+ raise Fallback()
228
+ if s[j] == '\\' and j + 1 < n:
229
+ buf.append(s[j:j + 2])
230
+ j += 2
231
+ continue
232
+ if s[j] == "'":
233
+ break
234
+ buf.append(s[j])
235
+ j += 1
236
+ try:
237
+ w['v'].append(''.join(buf).encode('latin-1', 'backslashreplace').decode('unicode_escape'))
238
+ except Exception:
239
+ w['exp'] = True
240
+ i = j + 1
241
+ continue
242
+ if c == '`' or (c == '$' and s.startswith('$(', i)):
243
+ raise Fallback()
244
+ if c == '$':
245
+ start()['exp'] = True
246
+ if c == '~' and st['w'] is None:
247
+ start()['tilde'] = True
248
+ if c == '{':
249
+ start()['ub'] = True
250
+ start()['v'].append(c)
251
+ i += 1
252
+ end(n)
253
+ if st['delim'] or pend:
254
+ raise Fallback()
255
+ return toks
256
+
257
+
258
+ def low(s):
259
+ return s.lower()
260
+
261
+
262
+ def main(d, cmd):
263
+ start = d.get('cwd')
264
+ if isinstance(start, str) and os.path.isdir(start):
265
+ start = os.path.realpath(start)
266
+ else:
267
+ start = os.getcwd()
268
+ root = os.environ.get('CLAUDE_PROJECT_DIR')
269
+ if not root:
270
+ try:
271
+ import subprocess
272
+ r = subprocess.run(['git', '-C', start, 'rev-parse', '--show-toplevel'],
273
+ capture_output=True, text=True, timeout=5)
274
+ root = r.stdout.strip() if r.returncode == 0 else ''
275
+ except Exception:
276
+ root = ''
277
+ root = root or start
278
+ root = os.path.realpath(root)
279
+ prot = [low(os.path.join(root, '.claude', 'rules')),
280
+ low(os.path.join(root, 'templates', '.claude', 'rules'))]
281
+
282
+ cmd = cmd.replace('\r', '').replace('\\\n', '')
283
+ if OPAQUE.search(cmd):
284
+ return 3
285
+ try:
286
+ toks = lex(cmd)
287
+ except Fallback:
288
+ return 3
289
+
290
+ hits = []
291
+ fb = [False]
292
+ shvars = {}
293
+
294
+ def rp(p):
295
+ b = os.path.basename(p)
296
+ if p.endswith('/') or b in ('', '.', '..'):
297
+ return os.path.realpath(p)
298
+ return os.path.join(os.path.realpath(os.path.dirname(p)), b)
299
+
300
+ def classify(r):
301
+ rl = low(r).rstrip('/') or '/'
302
+ for p in prot:
303
+ if rl == p or rl.startswith(p + '/'):
304
+ return 'inside'
305
+ for p in prot:
306
+ if rl == '/' or p.startswith(rl + '/'):
307
+ return 'anc'
308
+ return None
309
+
310
+ def value(w):
311
+ if w.brace:
312
+ raise Fallback()
313
+ v = w.v
314
+ if w.exp:
315
+ raw = w.raw
316
+ names = re.findall(r'\$\{(\w+)\}|\$(\w+)', raw)
317
+ if raw.count('$') != len(names) or "'" in raw or '\\' in raw:
318
+ raise Fallback()
319
+ for a, b in names:
320
+ if shvars.get(a or b) is None:
321
+ raise Fallback()
322
+ v = re.sub(r'\$\{(\w+)\}|\$(\w+)', lambda m: shvars[m.group(1) or m.group(2)], raw).replace('"', '')
323
+ if w.tilde:
324
+ if v == '~' or v.startswith('~/'):
325
+ v = os.path.expanduser(v)
326
+ else:
327
+ raise Fallback()
328
+ return v
329
+
330
+ def check(w, cwd, anc):
331
+ """anc: what an ANCESTOR of a rule dir means here: 'block' | 'fallback' | 'ignore'."""
332
+ try:
333
+ v = value(w)
334
+ if not os.path.isabs(v):
335
+ if cwd is None:
336
+ raise Fallback()
337
+ v = os.path.join(cwd, v)
338
+ except Fallback:
339
+ fb[0] = True
340
+ return
341
+ isglob = bool(GLOBCH.search(v))
342
+ if isglob:
343
+ static = []
344
+ for p in v.split('/'):
345
+ if GLOBCH.search(p):
346
+ break
347
+ static.append(p)
348
+ pre = os.path.realpath('/'.join(static) or '/')
349
+ if classify(pre) == 'inside':
350
+ hits.append((pre, True, 'inside'))
351
+ paths = [rp(m) for m in globmod.glob(v)]
352
+ else:
353
+ paths = [rp(v)]
354
+ for r in paths:
355
+ c = classify(r)
356
+ if c == 'inside':
357
+ hits.append((r, isglob, 'inside'))
358
+ elif c == 'anc':
359
+ if anc == 'block':
360
+ hits.append((r, isglob, 'anc'))
361
+ elif anc == 'fallback':
362
+ fb[0] = True
363
+
364
+ def names(words):
365
+ out = set()
366
+ for w in words:
367
+ if not w.exp and w.v and not any(ch.isspace() for ch in w.v):
368
+ out.add(os.path.basename(w.v) or w.v)
369
+ return out
370
+
371
+ def risky(words):
372
+ """True when an unmodelled position holds a destructive verb word."""
373
+ nm = names(words)
374
+ return bool(nm & DWORDS) or ('find' in nm and bool(nm & FINDACT)) or ('git' in nm and 'clean' in nm)
47
375
 
48
- # Single rule file
49
- filename=$(basename "$targets" 2>/dev/null || echo "$targets")
50
- echo "[Hook] ⛔ RULE DELETION BLOCKED" >&2
51
- echo "[Hook] Target: $filename" >&2
52
- echo "[Hook] Rule files require individual user confirmation before deletion." >&2
53
- echo "[Hook] Ask the user: \"정말 ${filename}을(를) 삭제하시겠습니까?\"" >&2
54
- echo "[Hook] Only proceed after explicit user approval." >&2
55
- exit 2
376
+ def unwrap(argv, cwd):
377
+ argv = list(argv)
378
+ while argv:
379
+ while argv and not argv[0].exp and VAR.match(argv[0].v):
380
+ argv.pop(0)
381
+ if not argv or argv[0].exp:
382
+ break
383
+ h = argv[0].v
384
+ if h in RESERVED:
385
+ argv.pop(0)
386
+ continue
387
+ b = os.path.basename(h)
388
+ if b not in WRAP:
389
+ break
390
+ vals, npos = WRAP[b]
391
+ argv.pop(0)
392
+ if b == 'busybox':
393
+ continue
394
+ while argv and argv[0].v.startswith('-') and not argv[0].exp:
395
+ o = argv.pop(0).v
396
+ if o == '--':
397
+ break
398
+ key, _, joined = o.partition('=')
399
+ if b == 'env' and key in ('-S', '--split-string'):
400
+ raise Fallback()
401
+ if b == 'env' and key in ('-C', '--chdir'):
402
+ if not joined:
403
+ if not argv:
404
+ break
405
+ dw = argv.pop(0)
406
+ else:
407
+ dw = Word(joined, '$' in joined, False, False, joined)
408
+ try:
409
+ dv = value(dw)
410
+ cwd = None if cwd is None and not os.path.isabs(dv) else \
411
+ os.path.realpath(dv if os.path.isabs(dv) else os.path.join(cwd, dv))
412
+ except Fallback:
413
+ cwd = None
414
+ continue
415
+ if o in vals and argv:
416
+ argv.pop(0)
417
+ for _ in range(npos):
418
+ if argv:
419
+ argv.pop(0)
420
+ return argv, cwd
421
+
422
+ def git_split(args, cwd):
423
+ i = 0
424
+ while i < len(args) and args[i].v.startswith('-'):
425
+ o = args[i].v
426
+ if o in GIT_VAL and i + 1 < len(args):
427
+ if o == '-C':
428
+ try:
429
+ p = value(args[i + 1])
430
+ cwd = None if (cwd is None and not os.path.isabs(p)) else \
431
+ os.path.realpath(p if os.path.isabs(p) else os.path.join(cwd, p))
432
+ except Fallback:
433
+ cwd = None
434
+ i += 2
435
+ else:
436
+ i += 1
437
+ if i >= len(args) or args[i].exp:
438
+ return None, [], cwd
439
+ return args[i].v, args[i + 1:], cwd
440
+
441
+ def kind(argv, cwd):
442
+ """'destr' | 'risky' | None for a (possibly wrapped) command."""
443
+ try:
444
+ argv, _ = unwrap(argv, cwd)
445
+ except Fallback:
446
+ return 'risky'
447
+ if not argv:
448
+ return None
449
+ if argv[0].exp:
450
+ return 'risky'
451
+ v = os.path.basename(argv[0].v)
452
+ if v in DESTR or v == 'mv':
453
+ return 'destr'
454
+ if v == 'git':
455
+ sub, _, _ = git_split(argv[1:], cwd)
456
+ if sub in ('rm', 'mv', 'clean'):
457
+ return 'destr'
458
+ return 'risky' if risky(argv[1:]) else None
459
+
460
+ def opts_pos(args, valopts=()):
461
+ opts, posl, i, rest = [], [], 0, False
462
+ while i < len(args):
463
+ a = args[i]
464
+ if rest or a.exp or not a.v.startswith('-') or a.v == '-':
465
+ posl.append(a)
466
+ elif a.v == '--':
467
+ rest = True
468
+ else:
469
+ opts.append(a.v)
470
+ if a.v in valopts and i + 1 < len(args):
471
+ i += 1
472
+ i += 1
473
+ return opts, posl
474
+
475
+ def short_has(opts, ch):
476
+ return any(o.startswith('-') and not o.startswith('--') and ch in o[1:] for o in opts)
477
+
478
+ def mv_targets(args, cwd):
479
+ dest, plain, i = None, [], 0
480
+ while i < len(args):
481
+ a = args[i].v
482
+ if a in ('-t', '--target-directory') and i + 1 < len(args):
483
+ dest = args[i + 1]
484
+ i += 2
485
+ continue
486
+ if a.startswith('--target-directory='):
487
+ j = a.split('=', 1)[1]
488
+ dest = Word(j, '$' in j, False, False, j)
489
+ elif a in ('-S', '--suffix') and i + 1 < len(args):
490
+ i += 2
491
+ continue
492
+ else:
493
+ plain.append(args[i])
494
+ i += 1
495
+ _, plain = opts_pos(plain)
496
+ if dest is None and plain:
497
+ dest = plain[-1]
498
+ plain = plain[:-1]
499
+ for s in plain:
500
+ check(s, cwd, 'block')
501
+ if dest is not None:
502
+ check(dest, cwd, 'ignore')
503
+
504
+ class Unsup(Exception):
505
+ pass
506
+
507
+ def find_compile(expr):
508
+ """Compile a find expression into a predicate over (disp, name, isdir, depth)."""
509
+ toks = [w.v for w in expr]
510
+ depth = {'max': None, 'min': 0}
511
+ pos = [0]
512
+ TRUEOPS = {'-delete', '-print', '-print0', '-ls', '-depth', '-d', '-xdev', '-mount', '-follow',
513
+ '-noleaf', '-ignore_readdir_race', '-true'}
514
+
515
+ def peek():
516
+ return toks[pos[0]] if pos[0] < len(toks) else None
517
+
518
+ def take():
519
+ t = peek()
520
+ pos[0] += 1
521
+ return t
522
+
523
+ def arg():
524
+ if pos[0] >= len(toks):
525
+ raise Unsup()
526
+ return take()
527
+
528
+ def primary():
529
+ t = take()
530
+ if t == '(':
531
+ e = p_or()
532
+ if take() != ')':
533
+ raise Unsup()
534
+ return e
535
+ if t in ('-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename'):
536
+ pat, ci, onpath = arg(), t.startswith('-i'), t not in ('-name', '-iname')
537
+ if ci:
538
+ pat = pat.lower()
539
+ return lambda c, pat=pat, ci=ci, onpath=onpath: fnmatch.fnmatchcase(
540
+ (c[0] if onpath else c[1]).lower() if ci else (c[0] if onpath else c[1]), pat)
541
+ if t == '-type':
542
+ ty = arg()
543
+ if not set(ty.split(',')) <= {'f', 'd'}:
544
+ raise Unsup()
545
+ tys = set(ty.split(','))
546
+ return lambda c, tys=tys: ('d' if c[2] else 'f') in tys
547
+ if t in ('-maxdepth', '-mindepth'):
548
+ n = arg()
549
+ if not n.isdigit():
550
+ raise Unsup()
551
+ depth['max' if t == '-maxdepth' else 'min'] = int(n)
552
+ return lambda c: True
553
+ if t in TRUEOPS:
554
+ return lambda c: True
555
+ if t in ('-exec', '-execdir', '-ok', '-okdir'):
556
+ while peek() is not None and peek() not in (';', '+'):
557
+ take()
558
+ if take() is None:
559
+ raise Unsup()
560
+ return lambda c: True
561
+ raise Unsup()
562
+
563
+ def p_not():
564
+ if peek() in ('!', '-not'):
565
+ take()
566
+ e = p_not()
567
+ return lambda c, e=e: not e(c)
568
+ return primary()
569
+
570
+ def p_and():
571
+ e = p_not()
572
+ while peek() is not None and peek() not in (')', '-o', '-or'):
573
+ if peek() in ('-a', '-and'):
574
+ take()
575
+ r = p_not()
576
+ e = (lambda l, r: lambda c: l(c) and r(c))(e, r)
577
+ return e
578
+
579
+ def p_or():
580
+ e = p_and()
581
+ while peek() in ('-o', '-or'):
582
+ take()
583
+ r = p_and()
584
+ e = (lambda l, r: lambda c: l(c) or r(c))(e, r)
585
+ return e
586
+
587
+ e = p_or() if toks else (lambda c: True)
588
+ if pos[0] != len(toks):
589
+ raise Unsup()
590
+ return e, depth
591
+
592
+ def find_start(a, cwd, expr):
593
+ try:
594
+ sv = value(a)
595
+ sabs = sv if os.path.isabs(sv) else (None if cwd is None else os.path.join(cwd, sv))
596
+ if sabs is None or GLOBCH.search(sv):
597
+ raise Fallback()
598
+ except Fallback:
599
+ fb[0] = True
600
+ return
601
+ sreal = os.path.realpath(sabs)
602
+ c = classify(sreal)
603
+ if c is None:
604
+ return
605
+ try:
606
+ pred, depth = find_compile(expr)
607
+ except Unsup:
608
+ rl = low(sreal).rstrip('/') or '/'
609
+ rootl = low(root)
610
+ if rl == rootl or rootl.startswith(rl.rstrip('/') + '/') or rl == '/':
611
+ fb[0] = True # project root or above: legacy decides
612
+ else:
613
+ hits.append((sreal, False, c))
614
+ return
615
+ sl = low(sreal).rstrip('/')
616
+ for p in prot:
617
+ if p == sl or p.startswith(sl + '/'):
618
+ walk_root = sreal + p[len(sl):]
619
+ elif sl.startswith(p + '/'):
620
+ walk_root = sreal
621
+ else:
622
+ continue
623
+ if not os.path.exists(walk_root):
624
+ continue
625
+ entries = [(walk_root, os.path.isdir(walk_root))]
626
+ if os.path.isdir(walk_root) and not os.path.islink(walk_root):
627
+ for dp, dns, fns in os.walk(walk_root):
628
+ entries += [(os.path.join(dp, x), True) for x in dns]
629
+ entries += [(os.path.join(dp, x), False) for x in fns]
630
+ for path, isdir in entries:
631
+ rel = os.path.relpath(path, sreal)
632
+ d = 0 if rel == '.' else rel.count(os.sep) + 1
633
+ if d < depth['min'] or (depth['max'] is not None and d > depth['max']):
634
+ continue
635
+ disp = sv if rel == '.' else (sv.rstrip('/') + '/' + rel)
636
+ name = os.path.basename(disp.rstrip('/')) or disp
637
+ if pred((disp, name, isdir, d)):
638
+ hits.append((path, False, 'inside'))
639
+ return
640
+
641
+ xargs_pipes = []
642
+ segs = []
643
+
644
+ def handle(argv, cwd, pipe_id):
645
+ try:
646
+ argv, cwd2 = unwrap(argv, cwd)
647
+ except Fallback:
648
+ fb[0] = True
649
+ return cwd
650
+ if not argv:
651
+ return cwd
652
+ if argv[0].exp:
653
+ fb[0] = True
654
+ return cwd
655
+ v = os.path.basename(argv[0].v)
656
+ args = argv[1:]
657
+ if v in ('cd', 'pushd', 'popd'):
658
+ if cwd2 is not cwd:
659
+ return None
660
+ if v != 'cd':
661
+ return None
662
+ if not args:
663
+ return os.path.expanduser('~')
664
+ tgt = [a for a in args if not (a.v.startswith('-') and a.v != '-')]
665
+ if len(tgt) != 1 or tgt[0].v == '-':
666
+ return None
667
+ try:
668
+ t = value(tgt[0])
669
+ except Fallback:
670
+ return None
671
+ if not os.path.isabs(t):
672
+ if cwd is None:
673
+ return None
674
+ t = os.path.join(cwd, t)
675
+ return os.path.realpath(t)
676
+ shell_cwd, cwd = cwd, cwd2
677
+ if v in DESTR:
678
+ opts, posl = opts_pos(args, {'-s', '--size', '-r', '--reference', '-n', '--iterations'}
679
+ if v in ('truncate', 'shred') else ())
680
+ rec = v == 'rm' and (short_has(opts, 'r') or short_has(opts, 'R') or '--recursive' in opts)
681
+ for a in posl:
682
+ check(a, cwd, 'block' if rec else 'fallback')
683
+ elif v == 'mv':
684
+ mv_targets(args, cwd)
685
+ elif v == 'git':
686
+ sub, rest, gcwd = git_split(args, cwd)
687
+ if sub is None:
688
+ if risky(args):
689
+ fb[0] = True
690
+ elif sub == 'rm':
691
+ opts, posl = opts_pos(rest, {'--pathspec-from-file'})
692
+ if not ('-n' in opts or '--dry-run' in opts or short_has(opts, 'n')):
693
+ rec = short_has(opts, 'r')
694
+ for a in posl:
695
+ check(a, gcwd, 'block' if rec else 'fallback')
696
+ elif sub == 'mv':
697
+ mv_targets(rest, gcwd)
698
+ elif sub == 'clean':
699
+ opts, posl = opts_pos(rest, {'-e', '--exclude'})
700
+ if not (short_has(opts, 'n') or '--dry-run' in opts):
701
+ rec = short_has(opts, 'd')
702
+ if posl:
703
+ for a in posl:
704
+ check(a, gcwd, 'block' if rec else 'ignore')
705
+ elif gcwd is None:
706
+ fb[0] = True
707
+ else:
708
+ check(Word(gcwd, False, False, False, gcwd), gcwd, 'block' if rec else 'ignore')
709
+ elif risky(rest):
710
+ fb[0] = True
711
+ elif v == 'find':
712
+ j, destructive = 0, '-delete' in [a.v for a in args]
713
+ while j < len(args):
714
+ if args[j].v in ('-exec', '-execdir', '-ok', '-okdir'):
715
+ k = j + 1
716
+ while k < len(args) and args[k].v not in (';', '+'):
717
+ k += 1
718
+ body = args[j + 1:k]
719
+ kd = kind(body, cwd) if body else None
720
+ if kd == 'destr':
721
+ destructive = True
722
+ elif kd == 'risky':
723
+ fb[0] = True
724
+ if body:
725
+ handle(body, cwd, pipe_id)
726
+ j = k
727
+ j += 1
728
+ if destructive:
729
+ k = 0
730
+ while k < len(args) and args[k].v in ('-H', '-L', '-P', '-D', '-E', '-X', '-d', '-s', '-x') \
731
+ or (k < len(args) and args[k].v.startswith('-O')):
732
+ k += 2 if args[k].v == '-D' else 1
733
+ starts = []
734
+ while k < len(args) and not (args[k].v.startswith('-') or args[k].v in ('(', '!', ')')):
735
+ starts.append(args[k])
736
+ k += 1
737
+ expr = args[k:]
738
+ for a in (starts or [Word('.', False, False, False, '.')]):
739
+ find_start(a, cwd, expr)
740
+ elif v == 'xargs':
741
+ i = 0
742
+ valopts = {'-I', '-J', '-L', '-n', '-P', '-R', '-S', '-s', '-E', '-a', '-d',
743
+ '--max-args', '--max-procs', '--delimiter', '--arg-file', '--max-chars',
744
+ '--process-slot-var'}
745
+ while i < len(args) and args[i].v.startswith('-') and not args[i].exp:
746
+ if args[i].v == '--':
747
+ i += 1
748
+ break
749
+ i += 2 if args[i].v in valopts else 1
750
+ inner = args[i:]
751
+ kd = kind(inner, cwd) if inner else None
752
+ if kd == 'destr':
753
+ xargs_pipes.append(pipe_id)
754
+ elif kd == 'risky':
755
+ fb[0] = True
756
+ if inner:
757
+ handle(inner, cwd, pipe_id)
758
+ elif risky(args):
759
+ fb[0] = True
760
+ return shell_cwd
761
+
762
+ cur, pipe_id, stack, cwd = [], 0, [], start
763
+ pending_redir = [None]
764
+
765
+ def flush():
766
+ nonlocal cwd
767
+ if pending_redir[0] is not None:
768
+ raise Fallback()
769
+ if not cur:
770
+ return
771
+ argv = [x for x in cur if not isinstance(x, tuple)]
772
+ for op, w in [x for x in cur if isinstance(x, tuple)]:
773
+ if op in WREDIR or (op == '>&' and not re.match(r'^(\d+|-)$', w.v)):
774
+ check(w, cwd, 'ignore')
775
+ if argv and all(not w.exp and VAR.match(w.v) for w in argv):
776
+ for w in argv:
777
+ k, _, val = w.v.partition('=')
778
+ shvars[k] = None if (w.brace or w.tilde or GLOBCH.search(val)) else val
779
+ segs.append((pipe_id, argv, cwd))
780
+ cwd = handle(argv, cwd, pipe_id)
781
+ del cur[:]
782
+
783
+ try:
784
+ for kindt, t in toks:
785
+ if kindt == 'w':
786
+ if pending_redir[0] is not None:
787
+ cur.append((pending_redir[0], t))
788
+ pending_redir[0] = None
789
+ else:
790
+ cur.append(t)
791
+ continue
792
+ if t in SEP:
793
+ flush()
794
+ if t == '(':
795
+ stack.append(cwd)
796
+ elif t == ')' and stack:
797
+ cwd = stack.pop()
798
+ if t not in ('|', '|&'):
799
+ pipe_id += 1
800
+ elif t in ('<<', '<<-'):
801
+ continue # delimiter and body already consumed by the lexer
802
+ elif t in WREDIR or t in OREDIR or t == '>&':
803
+ if pending_redir[0] is not None:
804
+ raise Fallback()
805
+ pending_redir[0] = t
806
+ else:
807
+ raise Fallback()
808
+ flush()
809
+ except Fallback:
810
+ return 3
811
+
812
+ for pid in set(xargs_pipes):
813
+ for sp, sargv, scwd in segs:
814
+ if sp != pid or not sargv or os.path.basename(sargv[0].v) == 'xargs':
815
+ continue
816
+ for a in sargv[1:]:
817
+ if a.v.startswith('-') or a.exp:
818
+ continue
819
+ check(a, scwd, 'fallback')
820
+
821
+ if not hits:
822
+ return 3 if fb[0] else 0
823
+
824
+ anc = [h for h in hits if h[2] == 'anc']
825
+ hnames = sorted({h[0] for h in hits})
826
+ if anc:
827
+ print("[Hook] ⛔ RULE DELETION BLOCKED — Parent directory deletion detected", file=sys.stderr)
828
+ print("[Hook] Target: %s (an ancestor of the project rule directories)" % anc[0][0], file=sys.stderr)
829
+ print("[Hook] This recursive delete/move would also remove .claude/rules (or templates/.claude/rules).",
830
+ file=sys.stderr)
831
+ print("[Hook] Delete rules individually with user confirmation.", file=sys.stderr)
832
+ print("[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지).", file=sys.stderr)
833
+ elif len(hnames) > 1 or any(h[1] for h in hits):
834
+ print("[Hook] ⛔ RULE DELETION BLOCKED — Multiple rules detected", file=sys.stderr)
835
+ print("[Hook] Targets: %s" % ", ".join(hnames), file=sys.stderr)
836
+ print("[Hook] Rule files must be deleted ONE AT A TIME with user confirmation.", file=sys.stderr)
837
+ print("[Hook] Delete each rule individually after asking: \"정말 {파일명}을(를) 삭제하시겠습니까?\"", file=sys.stderr)
838
+ print("[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지).", file=sys.stderr)
839
+ else:
840
+ fn = os.path.basename(hnames[0]) or hnames[0]
841
+ print("[Hook] ⛔ RULE DELETION BLOCKED", file=sys.stderr)
842
+ print("[Hook] Target: %s" % hnames[0], file=sys.stderr)
843
+ print("[Hook] Rule files require individual user confirmation before deletion.", file=sys.stderr)
844
+ print("[Hook] Ask the user: \"정말 %s을(를) 삭제하시겠습니까?\"" % fn, file=sys.stderr)
845
+ print("[Hook] Only proceed after explicit user approval.", file=sys.stderr)
846
+ print("[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지).", file=sys.stderr)
847
+ return 2
848
+
849
+
850
+ try:
851
+ data = json.loads(sys.stdin.buffer.read().decode('utf-8', 'replace'))
852
+ except Exception:
853
+ sys.exit(0)
854
+ if not isinstance(data, dict):
855
+ sys.exit(0)
856
+ tool = data.get('tool_name') or data.get('tool') or ''
857
+ ti = data.get('tool_input')
858
+ command = ti.get('command') if isinstance(ti, dict) else ''
859
+ if tool != 'Bash' or not isinstance(command, str) or not command:
860
+ sys.exit(0)
861
+ try:
862
+ rc = main(data, command)
863
+ except Exception:
864
+ rc = 3
865
+ if rc == 3:
866
+ sys.stdout.write(command)
867
+ sys.exit(rc)
868
+ PY
869
+ }
870
+
871
+ if command -v python3 &>/dev/null; then
872
+ # Cheap pre-filter: skip the interpreter unless a destructive verb word or '>' appears in the payload.
873
+ if ! printf '%s' "$input" | grep -qE '(^|[^[:alnum:]_]|\\[nrt]|\\u00)(rm|mv|unlink|rmdir|shred|truncate|find|xargs|clean)([^[:alnum:]_]|$)|>'; then
874
+ pass
875
+ fi
876
+
877
+ out=$(printf '%s' "$input" | run_analyzer)
878
+ rc=$?
879
+ case "$rc" in
880
+ 0) pass ;;
881
+ 2) exit 2 ;;
882
+ 3) legacy_check "$out" strict; pass ;;
883
+ *)
884
+ # Unexpected interpreter failure: fall back to legacy via jq if possible, else fail-open
885
+ if command -v jq &>/dev/null; then
886
+ cmd=$(printf '%s\n' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || pass
887
+ tool=$(printf '%s\n' "$input" | jq -r '.tool_name // .tool // ""' 2>/dev/null) || pass
888
+ [ "$tool" = "Bash" ] && legacy_check "$cmd" strict
889
+ fi
890
+ pass
891
+ ;;
892
+ esac
56
893
  fi
57
894
 
58
- # Not a rule deletion — pass through
59
- printf '%s\n' "$input"
60
- exit 0
895
+ # python3 absent: legacy regex logic (jq required)
896
+ if ! command -v jq &>/dev/null; then
897
+ pass
898
+ fi
899
+ tool=$(printf '%s\n' "$input" | jq -r '.tool_name // .tool // ""' 2>/dev/null) || pass
900
+ cmd=$(printf '%s\n' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || pass
901
+ if [ "$tool" != "Bash" ]; then
902
+ pass
903
+ fi
904
+ legacy_check "$cmd"
905
+ pass