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.
- package/README.md +1 -1
- package/dist/cli/index.js +435 -63
- package/dist/index.js +430 -58
- package/package.json +1 -1
- package/templates/.claude/agents/mgr-gitnerd.md +8 -1
- package/templates/.claude/hooks/scripts/adaptive-harness-scan.sh +2 -1
- package/templates/.claude/hooks/scripts/claude-md-reinject.sh +2 -1
- package/templates/.claude/hooks/scripts/omcustom-auto-update.sh +7 -0
- package/templates/.claude/hooks/scripts/rule-deletion-guard.sh +890 -45
- package/templates/.claude/hooks/scripts/session-autofix.sh +7 -0
- package/templates/.claude/hooks/scripts/session-env-check.sh +7 -0
- package/templates/.claude/hooks/scripts/stale-todo-scanner.sh +7 -0
- package/templates/.claude/rules/SHOULD-hud-statusline.md +2 -2
- package/templates/.claude/skills/claude-native/SKILL.md +2 -2
- package/templates/.claude/skills/homework/SKILL.md +2 -0
- package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +18 -4
- package/templates/.claude/skills/playwright-compress/SKILL.md +2 -2
- package/templates/guides/middleware-patterns/README.md +3 -3
- package/templates/manifest.json +1 -1
- package/templates/workflows/auto-dev.yaml +18 -4
|
@@ -1,60 +1,905 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# rule-deletion-guard.sh — Block rule file deletion without individual user confirmation
|
|
3
|
-
#
|
|
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
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
#
|
|
20
|
-
if
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
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
|