@paths.design/caws-cli 11.1.0 → 11.1.2
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/dist/shell/commands/worktree.d.ts +1 -2
- package/dist/shell/commands/worktree.d.ts.map +1 -1
- package/package.json +6 -3
- package/templates/hook-packs/claude-code/CLAUDE.md +172 -0
- package/templates/hook-packs/claude-code/audit.sh +121 -0
- package/templates/hook-packs/claude-code/block-dangerous.sh +158 -0
- package/templates/hook-packs/claude-code/classify_command.py +1064 -0
- package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +63 -0
- package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +50 -0
- package/templates/hook-packs/claude-code/dispatch/session_start.sh +41 -0
- package/templates/hook-packs/claude-code/dispatch/stop.sh +37 -0
- package/templates/hook-packs/claude-code/guard-strikes.sh +140 -0
- package/templates/hook-packs/claude-code/lib/parse-input.sh +127 -0
- package/templates/hook-packs/claude-code/lib/run-handlers.sh +212 -0
- package/templates/hook-packs/claude-code/reset-danger-latch.sh +21 -0
- package/templates/hook-packs/claude-code/reset-strikes.sh +243 -0
- package/templates/hook-packs/claude-code/runtime-paths.sh +80 -0
- package/templates/hook-packs/claude-code/scope-guard.sh +392 -0
- package/templates/hook-packs/claude-code/session-caws-status.sh +171 -0
- package/templates/hook-packs/claude-code/session-log.sh +180 -0
- package/templates/hook-packs/claude-code/worktree-guard.sh +240 -0
- package/templates/hook-packs/claude-code/worktree-write-guard.sh +77 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# CAWS-MANAGED-HOOK
|
|
3
|
+
# hook_pack: claude-code
|
|
4
|
+
# hook_pack_version: 2
|
|
5
|
+
# caws_min_major: 11
|
|
6
|
+
# lineage_refs: 1,17
|
|
7
|
+
# do_not_edit_directly: update via `caws init --agent-surface claude-code`
|
|
8
|
+
"""
|
|
9
|
+
Command safety classifier for Claude Code PreToolUse hooks.
|
|
10
|
+
|
|
11
|
+
Segments shell commands, parses them individually, and classifies each
|
|
12
|
+
as allow / confirm / deny based on tiered policy.
|
|
13
|
+
|
|
14
|
+
Output: JSON object with keys:
|
|
15
|
+
decision: "allow" | "ask" | "deny"
|
|
16
|
+
reason: human-readable explanation (empty string for allow)
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
echo "$COMMAND" | python3 classify_command.py [--repo-root DIR] [--home DIR]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shlex
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Sequence
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Configuration
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
# Paths that are safe targets for recursive deletion (relative to repo root).
|
|
38
|
+
# After normalization, if the resolved path starts with one of these, allow.
|
|
39
|
+
SAFE_DELETE_PREFIXES: list[str] = [
|
|
40
|
+
"target/",
|
|
41
|
+
"tmp/",
|
|
42
|
+
".pytest_cache/",
|
|
43
|
+
"node_modules/",
|
|
44
|
+
"__pycache__/",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Pipeline-aware deny patterns: matched against the FULL raw command string
|
|
48
|
+
# BEFORE segmentation. These detect cross-pipeline dangers like curl|sh and
|
|
49
|
+
# fork bombs whose syntax spans segment boundaries.
|
|
50
|
+
DENY_PIPELINE_PATTERNS: list[tuple[str, str]] = [
|
|
51
|
+
# Pipe-to-shell (network exfiltration) — must match across | boundary
|
|
52
|
+
(r"\b(curl|wget)\b.*\|\s*(ba)?sh\b", "pipe-to-shell execution"),
|
|
53
|
+
# Fork bombs — special syntax that segmentation mangles
|
|
54
|
+
(r":\(\)\s*\{.*:\|:.*\}\s*;\s*:", "fork bomb"),
|
|
55
|
+
(r"\bwhile\s+true\b.*\bfork\b", "fork loop"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Segment-level regex patterns that are always hard-blocked.
|
|
59
|
+
# These are matched against individual parsed command segments, NOT the raw
|
|
60
|
+
# command string. Quoted literals in other segments will not trigger them.
|
|
61
|
+
DENY_SEGMENT_PATTERNS: list[tuple[str, str]] = [
|
|
62
|
+
# System destruction
|
|
63
|
+
(r"\bdd\b.*\bif=/dev/(zero|random)\b", "dd with destructive input"),
|
|
64
|
+
(r"\bmkfs\.", "filesystem format"),
|
|
65
|
+
(r"\bfdisk\b", "disk partitioning"),
|
|
66
|
+
(r">\s*/dev/sd", "raw device write"),
|
|
67
|
+
# Permission escalation
|
|
68
|
+
(r"\bchmod\b.*\+s\b", "setuid/setgid bit"),
|
|
69
|
+
# System control
|
|
70
|
+
(r"\b(shutdown|reboot)\b", "system shutdown/reboot"),
|
|
71
|
+
(r"\binit\s+[06]\b", "system runlevel change"),
|
|
72
|
+
# CAWS spec/policy/waiver protection (RC defect #8).
|
|
73
|
+
# Naked rm/mv on .caws/specs/, .caws/policy.yaml, or .caws/waivers/ bypasses
|
|
74
|
+
# the audit trail. Use `caws specs delete|archive`, `caws waivers revoke`,
|
|
75
|
+
# or edit policy.yaml in place via Edit (not Bash) instead.
|
|
76
|
+
(r"\b(rm|mv)\b[^\n]*\.caws/specs/[^\s'\"]*\.ya?ml\b",
|
|
77
|
+
"naked rm/mv on .caws/specs/*.yaml — use `caws specs delete|archive <id>`"),
|
|
78
|
+
(r"\b(rm|mv)\b[^\n]*\.caws/policy\.ya?ml\b",
|
|
79
|
+
"naked rm/mv on .caws/policy.yaml — policy is governed; use Edit and a CAWS waiver"),
|
|
80
|
+
(r"\b(rm|mv)\b[^\n]*\.caws/waivers/[^\s'\"]*\.ya?ml\b",
|
|
81
|
+
"naked rm/mv on .caws/waivers/*.yaml — use `caws waivers revoke <id>`"),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Segment-level regex patterns that require user confirmation.
|
|
85
|
+
CONFIRM_SEGMENT_PATTERNS: list[tuple[str, str]] = [
|
|
86
|
+
# Git destructive operations
|
|
87
|
+
(r"\bgit\s+reset\s+--hard\b", "git reset --hard"),
|
|
88
|
+
(r"\bgit\s+push\s+(-f\b|--force\b|--force-with-lease\b)", "git force push"),
|
|
89
|
+
(r"\bgit\s+clean\s+-[a-zA-Z]*f", "git clean with force"),
|
|
90
|
+
(r"\bgit\s+checkout\s+\.\s*$", "git checkout . (discard all changes)"),
|
|
91
|
+
(r"\bgit\s+restore\s+\.\s*$", "git restore . (discard all changes)"),
|
|
92
|
+
(r"\bgit\s+rebase\b", "git rebase (rewrites branch history)"),
|
|
93
|
+
(r"\bgit\s+cherry-pick\b", "git cherry-pick (replays commits across branches)"),
|
|
94
|
+
# chmod 777
|
|
95
|
+
(r"\bchmod\b.*\b777\b", "chmod 777"),
|
|
96
|
+
# History manipulation
|
|
97
|
+
(r"\bhistory\s+-c\b", "history clear"),
|
|
98
|
+
# sudo (not in allowed list)
|
|
99
|
+
(r"^sudo\s+(?!npm|yarn|pnpm|brew|apt-get|apt|dnf|yum)", "sudo command"),
|
|
100
|
+
# venv creation (sprawl prevention)
|
|
101
|
+
(r"\bpython3?\s+-m\s+venv\b", "virtual environment creation"),
|
|
102
|
+
(r"\bvirtualenv\s", "virtual environment creation"),
|
|
103
|
+
(r"\bconda\s+create\b", "conda environment creation"),
|
|
104
|
+
# Credential file reads
|
|
105
|
+
(r"\bcat\b.*\.(env|ssh/|aws/)", "credential file read"),
|
|
106
|
+
(r"\bcat\b.*/etc/(passwd|shadow)\b", "system credential read"),
|
|
107
|
+
(r"\bcat\b.*(id_rsa|credentials)\b", "credential file read"),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
GIT_GLOBAL_OPTIONS_WITH_VALUE: set[str] = {
|
|
111
|
+
"-C",
|
|
112
|
+
"-c",
|
|
113
|
+
"--git-dir",
|
|
114
|
+
"--work-tree",
|
|
115
|
+
"--namespace",
|
|
116
|
+
"--exec-path",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
GIT_GLOBAL_OPTIONS_NO_VALUE: set[str] = {
|
|
120
|
+
"--bare",
|
|
121
|
+
"--no-pager",
|
|
122
|
+
"--paginate",
|
|
123
|
+
"--no-replace-objects",
|
|
124
|
+
"--literal-pathspecs",
|
|
125
|
+
"--glob-pathspecs",
|
|
126
|
+
"--noglob-pathspecs",
|
|
127
|
+
"--icase-pathspecs",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
COMMAND_WRAPPERS: set[str] = {
|
|
131
|
+
"builtin",
|
|
132
|
+
"command",
|
|
133
|
+
"nohup",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
SHELL_C_WRAPPERS: set[str] = {
|
|
137
|
+
"bash",
|
|
138
|
+
"dash",
|
|
139
|
+
"sh",
|
|
140
|
+
"zsh",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Command segmentation
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def segment_command(raw: str) -> list[str]:
|
|
149
|
+
"""Split a shell command string on &&, ||, ;, | operators.
|
|
150
|
+
|
|
151
|
+
Respects quoted strings so that e.g. git commit -m "rm -rf /" does not
|
|
152
|
+
split inside the quotes. Returns individual command segments with
|
|
153
|
+
leading/trailing whitespace stripped.
|
|
154
|
+
|
|
155
|
+
This is intentionally conservative: if we cannot parse, we return
|
|
156
|
+
the entire string as one segment so it still gets classified.
|
|
157
|
+
"""
|
|
158
|
+
segments: list[str] = []
|
|
159
|
+
current: list[str] = []
|
|
160
|
+
i = 0
|
|
161
|
+
in_single = False
|
|
162
|
+
in_double = False
|
|
163
|
+
in_heredoc: str | None = None
|
|
164
|
+
heredoc_marker: str = ""
|
|
165
|
+
|
|
166
|
+
while i < len(raw):
|
|
167
|
+
ch = raw[i]
|
|
168
|
+
|
|
169
|
+
# ---- heredoc detection ----
|
|
170
|
+
# Look for <<EOF or <<'EOF' or <<"EOF" at segment level
|
|
171
|
+
if not in_single and not in_double and in_heredoc is None:
|
|
172
|
+
if raw[i:i+2] == "<<":
|
|
173
|
+
# Extract the delimiter
|
|
174
|
+
j = i + 2
|
|
175
|
+
while j < len(raw) and raw[j] in (' ', '\t'):
|
|
176
|
+
j += 1
|
|
177
|
+
# Strip optional quotes around delimiter
|
|
178
|
+
quote_char = None
|
|
179
|
+
if j < len(raw) and raw[j] in ("'", '"'):
|
|
180
|
+
quote_char = raw[j]
|
|
181
|
+
j += 1
|
|
182
|
+
k = j
|
|
183
|
+
while k < len(raw) and raw[k] not in (' ', '\t', '\n', "'", '"', ')'):
|
|
184
|
+
k += 1
|
|
185
|
+
if k > j:
|
|
186
|
+
heredoc_marker = raw[j:k]
|
|
187
|
+
in_heredoc = heredoc_marker
|
|
188
|
+
# Skip to end of this line
|
|
189
|
+
nl = raw.find('\n', i)
|
|
190
|
+
if nl >= 0:
|
|
191
|
+
current.append(raw[i:nl+1])
|
|
192
|
+
i = nl + 1
|
|
193
|
+
else:
|
|
194
|
+
current.append(raw[i:])
|
|
195
|
+
i = len(raw)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# ---- inside heredoc: scan for closing marker ----
|
|
199
|
+
if in_heredoc is not None:
|
|
200
|
+
nl = raw.find('\n', i)
|
|
201
|
+
if nl < 0:
|
|
202
|
+
# No newline found, rest is heredoc content
|
|
203
|
+
current.append(raw[i:])
|
|
204
|
+
i = len(raw)
|
|
205
|
+
continue
|
|
206
|
+
line = raw[i:nl]
|
|
207
|
+
current.append(raw[i:nl+1])
|
|
208
|
+
i = nl + 1
|
|
209
|
+
if line.strip() == in_heredoc:
|
|
210
|
+
in_heredoc = None
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# ---- quoting ----
|
|
214
|
+
if ch == '\\' and not in_single:
|
|
215
|
+
current.append(raw[i:i+2])
|
|
216
|
+
i += 2
|
|
217
|
+
continue
|
|
218
|
+
if ch == "'" and not in_double:
|
|
219
|
+
in_single = not in_single
|
|
220
|
+
current.append(ch)
|
|
221
|
+
i += 1
|
|
222
|
+
continue
|
|
223
|
+
if ch == '"' and not in_single:
|
|
224
|
+
in_double = not in_double
|
|
225
|
+
current.append(ch)
|
|
226
|
+
i += 1
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# ---- segment separators (only outside quotes) ----
|
|
230
|
+
if not in_single and not in_double:
|
|
231
|
+
# && or ||
|
|
232
|
+
if raw[i:i+2] in ('&&', '||'):
|
|
233
|
+
seg = ''.join(current).strip()
|
|
234
|
+
if seg:
|
|
235
|
+
segments.append(seg)
|
|
236
|
+
current = []
|
|
237
|
+
i += 2
|
|
238
|
+
continue
|
|
239
|
+
# ; (but not ;;)
|
|
240
|
+
if ch == ';' and (i + 1 >= len(raw) or raw[i+1] != ';'):
|
|
241
|
+
seg = ''.join(current).strip()
|
|
242
|
+
if seg:
|
|
243
|
+
segments.append(seg)
|
|
244
|
+
current = []
|
|
245
|
+
i += 1
|
|
246
|
+
continue
|
|
247
|
+
# | (but not ||, already handled above)
|
|
248
|
+
if ch == '|':
|
|
249
|
+
seg = ''.join(current).strip()
|
|
250
|
+
if seg:
|
|
251
|
+
segments.append(seg)
|
|
252
|
+
current = []
|
|
253
|
+
i += 1
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
current.append(ch)
|
|
257
|
+
i += 1
|
|
258
|
+
|
|
259
|
+
seg = ''.join(current).strip()
|
|
260
|
+
if seg:
|
|
261
|
+
segments.append(seg)
|
|
262
|
+
|
|
263
|
+
return segments if segments else [raw.strip()]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def strip_quotes(s: str) -> str:
|
|
267
|
+
"""Remove surrounding quotes from a shell token."""
|
|
268
|
+
if len(s) >= 2:
|
|
269
|
+
if (s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'"):
|
|
270
|
+
return s[1:-1]
|
|
271
|
+
return s
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def command_basename(token: str) -> str:
|
|
275
|
+
"""Return the executable basename for a command token."""
|
|
276
|
+
return Path(token).name
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def is_assignment_token(token: str) -> bool:
|
|
280
|
+
"""Return true for shell-style NAME=value assignment tokens."""
|
|
281
|
+
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token) is not None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def skip_env_prefix(tokens: Sequence[str], index: int) -> tuple[int, list[str] | None]:
|
|
285
|
+
"""Skip env options and assignments after an env wrapper."""
|
|
286
|
+
i = index
|
|
287
|
+
while i < len(tokens):
|
|
288
|
+
tok = tokens[i]
|
|
289
|
+
if tok == "--":
|
|
290
|
+
return i + 1, None
|
|
291
|
+
if is_assignment_token(tok):
|
|
292
|
+
i += 1
|
|
293
|
+
continue
|
|
294
|
+
if tok in ("-i", "-0", "--ignore-environment", "--null"):
|
|
295
|
+
i += 1
|
|
296
|
+
continue
|
|
297
|
+
if tok in ("-u", "--unset", "-C", "--chdir", "-S", "--split-string"):
|
|
298
|
+
if tok in ("-S", "--split-string") and i + 1 < len(tokens):
|
|
299
|
+
return i, [" ".join(tokens[i + 1:])]
|
|
300
|
+
i += 2
|
|
301
|
+
continue
|
|
302
|
+
if tok.startswith("--split-string="):
|
|
303
|
+
nested = tok.split("=", 1)[1]
|
|
304
|
+
if i + 1 < len(tokens):
|
|
305
|
+
nested = " ".join([nested, *tokens[i + 1:]])
|
|
306
|
+
return i, [nested]
|
|
307
|
+
if tok.startswith("--unset=") or tok.startswith("--chdir=") or tok.startswith("--split-string="):
|
|
308
|
+
i += 1
|
|
309
|
+
continue
|
|
310
|
+
return i, None
|
|
311
|
+
return i, None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def normalize_command_tokens(tokens: Sequence[str]) -> tuple[int, list[str] | None]:
|
|
315
|
+
"""Strip variable assignments and simple command wrappers.
|
|
316
|
+
|
|
317
|
+
Returns the index of the real command. If the command is a shell -c wrapper,
|
|
318
|
+
returns a nested command string list so the caller can classify it
|
|
319
|
+
recursively.
|
|
320
|
+
"""
|
|
321
|
+
i = 0
|
|
322
|
+
while i < len(tokens):
|
|
323
|
+
tok = tokens[i]
|
|
324
|
+
base = command_basename(tok)
|
|
325
|
+
|
|
326
|
+
if is_assignment_token(tok):
|
|
327
|
+
i += 1
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
if base == "env":
|
|
331
|
+
i, nested = skip_env_prefix(tokens, i + 1)
|
|
332
|
+
if nested is not None:
|
|
333
|
+
return i, nested
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
if base == "time":
|
|
337
|
+
i += 1
|
|
338
|
+
while i < len(tokens) and tokens[i].startswith("-"):
|
|
339
|
+
if tokens[i] in ("-f", "-o"):
|
|
340
|
+
i += 2
|
|
341
|
+
else:
|
|
342
|
+
i += 1
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
if base in COMMAND_WRAPPERS:
|
|
346
|
+
i += 1
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if base in SHELL_C_WRAPPERS:
|
|
350
|
+
j = i + 1
|
|
351
|
+
while j < len(tokens):
|
|
352
|
+
arg = tokens[j]
|
|
353
|
+
if arg == "--":
|
|
354
|
+
j += 1
|
|
355
|
+
continue
|
|
356
|
+
if arg.startswith("-") and "c" in arg[1:]:
|
|
357
|
+
if j + 1 < len(tokens):
|
|
358
|
+
return i, [tokens[j + 1]]
|
|
359
|
+
return i, [""]
|
|
360
|
+
if not arg.startswith("-"):
|
|
361
|
+
break
|
|
362
|
+
j += 1
|
|
363
|
+
|
|
364
|
+
return i, None
|
|
365
|
+
|
|
366
|
+
return i, None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def detect_git_subcommand(segment: str) -> str | None:
|
|
370
|
+
"""Detect the semantic Git subcommand for one executable segment.
|
|
371
|
+
|
|
372
|
+
This recognizes wrappers such as env/command/nohup/time, absolute Git
|
|
373
|
+
executable paths, and Git global options before the real subcommand.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
tokens = shlex.split(segment)
|
|
377
|
+
except ValueError:
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
if not tokens:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
start, nested = normalize_command_tokens(tokens)
|
|
384
|
+
if nested is not None:
|
|
385
|
+
return None
|
|
386
|
+
if start >= len(tokens) or command_basename(tokens[start]) != "git":
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
i = start + 1
|
|
390
|
+
while i < len(tokens):
|
|
391
|
+
tok = tokens[i]
|
|
392
|
+
if tok == "--":
|
|
393
|
+
i += 1
|
|
394
|
+
break
|
|
395
|
+
if tok in GIT_GLOBAL_OPTIONS_WITH_VALUE:
|
|
396
|
+
i += 2
|
|
397
|
+
continue
|
|
398
|
+
if any(tok.startswith(f"{opt}=") for opt in GIT_GLOBAL_OPTIONS_WITH_VALUE if opt.startswith("--")):
|
|
399
|
+
i += 1
|
|
400
|
+
continue
|
|
401
|
+
if tok in GIT_GLOBAL_OPTIONS_NO_VALUE:
|
|
402
|
+
i += 1
|
|
403
|
+
continue
|
|
404
|
+
if tok.startswith("-"):
|
|
405
|
+
# Unknown global option. If it has an inline value, skip it;
|
|
406
|
+
# otherwise stop so we do not accidentally skip a subcommand.
|
|
407
|
+
if "=" in tok:
|
|
408
|
+
i += 1
|
|
409
|
+
continue
|
|
410
|
+
break
|
|
411
|
+
return tok
|
|
412
|
+
|
|
413
|
+
if i < len(tokens) and not tokens[i].startswith("-"):
|
|
414
|
+
return tokens[i]
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def git_alias_value_invokes_init(value: str) -> bool:
|
|
419
|
+
"""Return true when a `git -c alias.*=...` value routes to init."""
|
|
420
|
+
stripped = value.strip()
|
|
421
|
+
if not stripped:
|
|
422
|
+
return False
|
|
423
|
+
if stripped == "init" or stripped.startswith("init "):
|
|
424
|
+
return True
|
|
425
|
+
if stripped.startswith("!"):
|
|
426
|
+
nested = stripped[1:].strip()
|
|
427
|
+
return detect_git_subcommand(nested) == "init" or nested == "init" or nested.startswith("init ")
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def has_git_init_alias_config(segment: str) -> bool:
|
|
432
|
+
"""Detect inline Git alias definitions that route an alias to init."""
|
|
433
|
+
try:
|
|
434
|
+
tokens = shlex.split(segment)
|
|
435
|
+
except ValueError:
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
if not tokens:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
start, nested = normalize_command_tokens(tokens)
|
|
442
|
+
if nested is not None or start >= len(tokens) or command_basename(tokens[start]) != "git":
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
i = start + 1
|
|
446
|
+
while i < len(tokens):
|
|
447
|
+
tok = tokens[i]
|
|
448
|
+
config_value = None
|
|
449
|
+
if tok == "-c" and i + 1 < len(tokens):
|
|
450
|
+
config_value = tokens[i + 1]
|
|
451
|
+
i += 2
|
|
452
|
+
elif tok.startswith("-c") and len(tok) > 2:
|
|
453
|
+
config_value = tok[2:]
|
|
454
|
+
i += 1
|
|
455
|
+
else:
|
|
456
|
+
i += 1
|
|
457
|
+
|
|
458
|
+
if not config_value or "=" not in config_value:
|
|
459
|
+
continue
|
|
460
|
+
key, value = config_value.split("=", 1)
|
|
461
|
+
if key.startswith("alias.") and git_alias_value_invokes_init(value):
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def classify_nested_shell(segment: str, repo_root: Path, home: Path, cwd: Path, caws_worktree: bool) -> tuple[str, str] | None:
|
|
468
|
+
"""Recursively classify sh/bash/zsh -c strings."""
|
|
469
|
+
try:
|
|
470
|
+
tokens = shlex.split(segment)
|
|
471
|
+
except ValueError:
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
_, nested = normalize_command_tokens(tokens)
|
|
475
|
+
if not nested:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
return classify_command(nested[0], repo_root, home, cwd, caws_worktree)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def classify_git_semantics(
|
|
482
|
+
segment: str,
|
|
483
|
+
caws_worktree: bool,
|
|
484
|
+
repo_root: Path | None = None,
|
|
485
|
+
) -> tuple[str, str] | None:
|
|
486
|
+
"""Classify Git operations by executable/subcommand semantics.
|
|
487
|
+
|
|
488
|
+
When `caws_worktree` is true (a trusted git-init context exists) and
|
|
489
|
+
the segment is a git-init variant, the trusted token is consumed
|
|
490
|
+
here. If consumption fails (the token was removed by a concurrent
|
|
491
|
+
classifier run, or another git-init segment in the same command
|
|
492
|
+
already consumed it), the segment falls back to `ask` so the human
|
|
493
|
+
review boundary still engages.
|
|
494
|
+
"""
|
|
495
|
+
is_init_alias = has_git_init_alias_config(segment)
|
|
496
|
+
subcommand = detect_git_subcommand(segment) if not is_init_alias else None
|
|
497
|
+
|
|
498
|
+
if is_init_alias:
|
|
499
|
+
if caws_worktree and repo_root is not None and consume_trusted_git_init_context(repo_root):
|
|
500
|
+
return "allow", ""
|
|
501
|
+
return "ask", "git alias routes to init and requires human approval"
|
|
502
|
+
|
|
503
|
+
if subcommand is None:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
if subcommand == "init":
|
|
507
|
+
if caws_worktree and repo_root is not None and consume_trusted_git_init_context(repo_root):
|
|
508
|
+
return "allow", ""
|
|
509
|
+
return "ask", "git init requires human approval; do not retry by wrapping, reordering, aliasing, or indirect invocation"
|
|
510
|
+
|
|
511
|
+
if subcommand == "rebase":
|
|
512
|
+
return "ask", "git rebase rewrites branch history"
|
|
513
|
+
|
|
514
|
+
if subcommand == "cherry-pick":
|
|
515
|
+
return "ask", "git cherry-pick replays commits across branches"
|
|
516
|
+
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _trusted_git_init_token_path(repo_root: Path) -> Path | None:
|
|
521
|
+
"""Return the trusted git-init allow-token path if the env signals it.
|
|
522
|
+
|
|
523
|
+
Validation only — does not check disk presence and does not consume.
|
|
524
|
+
"""
|
|
525
|
+
if os.environ.get("CAWS_TRUSTED_WORKTREE_CREATE_CONTEXT", "0") != "1":
|
|
526
|
+
return None
|
|
527
|
+
nonce = os.environ.get("CAWS_TRUSTED_HOOK_NONCE", "")
|
|
528
|
+
if not re.match(r"^[A-Za-z0-9._-]{8,128}$", nonce):
|
|
529
|
+
return None
|
|
530
|
+
return repo_root / ".claude" / "hooks" / "state" / f"allow-git-init-{nonce}"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def has_trusted_git_init_context(repo_root: Path) -> bool:
|
|
534
|
+
"""Return true when dispatch created a one-shot git-init allow token."""
|
|
535
|
+
token = _trusted_git_init_token_path(repo_root)
|
|
536
|
+
return token is not None and token.is_file()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def consume_trusted_git_init_context(repo_root: Path) -> bool:
|
|
540
|
+
"""Atomically consume the trusted git-init allow token.
|
|
541
|
+
|
|
542
|
+
Returns true if a valid token existed and was removed. The token is
|
|
543
|
+
one-shot: a subsequent git-init in the same dispatch will be subject
|
|
544
|
+
to normal classification (which means `ask`). Dispatch must mint a
|
|
545
|
+
fresh nonce + token for each authorized lifecycle operation.
|
|
546
|
+
"""
|
|
547
|
+
token = _trusted_git_init_token_path(repo_root)
|
|
548
|
+
if token is None or not token.is_file():
|
|
549
|
+
return False
|
|
550
|
+
try:
|
|
551
|
+
token.unlink()
|
|
552
|
+
except OSError:
|
|
553
|
+
return False
|
|
554
|
+
return True
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def extract_command_word(segment: str) -> str:
|
|
558
|
+
"""Extract the first command word from a segment.
|
|
559
|
+
|
|
560
|
+
Strips leading variable assignments (FOO=bar), env prefixes,
|
|
561
|
+
and common wrappers like 'time'.
|
|
562
|
+
"""
|
|
563
|
+
try:
|
|
564
|
+
tokens = shlex.split(segment)
|
|
565
|
+
except ValueError:
|
|
566
|
+
# Malformed quoting — return raw first word
|
|
567
|
+
return segment.split()[0] if segment.split() else ""
|
|
568
|
+
|
|
569
|
+
for tok in tokens:
|
|
570
|
+
# Skip variable assignments
|
|
571
|
+
if '=' in tok and not tok.startswith('-'):
|
|
572
|
+
continue
|
|
573
|
+
# Skip common prefixes
|
|
574
|
+
if tok in ('env', 'time', 'nice', 'nohup', 'command', 'builtin'):
|
|
575
|
+
continue
|
|
576
|
+
return tok
|
|
577
|
+
return ""
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# rm classifier
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def is_recursive_rm(segment: str) -> tuple[bool, list[str]]:
|
|
585
|
+
"""Check if a segment is an rm command with recursive flags.
|
|
586
|
+
|
|
587
|
+
Returns (is_recursive, [target_paths]).
|
|
588
|
+
"""
|
|
589
|
+
try:
|
|
590
|
+
tokens = shlex.split(segment)
|
|
591
|
+
except ValueError:
|
|
592
|
+
# Cannot parse — be conservative
|
|
593
|
+
if re.search(r'\brm\b', segment) and re.search(r'-[a-zA-Z]*r', segment):
|
|
594
|
+
return True, []
|
|
595
|
+
return False, []
|
|
596
|
+
|
|
597
|
+
if not tokens:
|
|
598
|
+
return False, []
|
|
599
|
+
|
|
600
|
+
# Find the rm command (skip env/time prefixes)
|
|
601
|
+
rm_idx = -1
|
|
602
|
+
for idx, tok in enumerate(tokens):
|
|
603
|
+
if tok in ('env', 'time', 'nice', 'nohup', 'command', 'builtin'):
|
|
604
|
+
continue
|
|
605
|
+
if '=' in tok and not tok.startswith('-'):
|
|
606
|
+
continue
|
|
607
|
+
if tok == 'rm':
|
|
608
|
+
rm_idx = idx
|
|
609
|
+
break
|
|
610
|
+
|
|
611
|
+
if rm_idx < 0:
|
|
612
|
+
return False, []
|
|
613
|
+
|
|
614
|
+
# Check for recursive flag
|
|
615
|
+
is_recursive = False
|
|
616
|
+
targets: list[str] = []
|
|
617
|
+
i = rm_idx + 1
|
|
618
|
+
while i < len(tokens):
|
|
619
|
+
tok = tokens[i]
|
|
620
|
+
if tok == '--':
|
|
621
|
+
# Everything after -- is targets
|
|
622
|
+
targets.extend(tokens[i+1:])
|
|
623
|
+
break
|
|
624
|
+
if tok.startswith('-') and not tok.startswith('--'):
|
|
625
|
+
if 'r' in tok or 'R' in tok:
|
|
626
|
+
is_recursive = True
|
|
627
|
+
elif tok.startswith('--'):
|
|
628
|
+
if tok == '--recursive':
|
|
629
|
+
is_recursive = True
|
|
630
|
+
# Other long options: skip
|
|
631
|
+
else:
|
|
632
|
+
targets.append(tok)
|
|
633
|
+
i += 1
|
|
634
|
+
|
|
635
|
+
return is_recursive, targets
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def classify_rm_target(
|
|
639
|
+
target: str,
|
|
640
|
+
repo_root: Path,
|
|
641
|
+
home: Path,
|
|
642
|
+
cwd: Path,
|
|
643
|
+
) -> tuple[str, str]:
|
|
644
|
+
"""Classify a single rm target path.
|
|
645
|
+
|
|
646
|
+
Returns ("deny"|"ask"|"allow", reason).
|
|
647
|
+
"""
|
|
648
|
+
# Resolve the target to an absolute path
|
|
649
|
+
raw = target.strip()
|
|
650
|
+
if not raw:
|
|
651
|
+
return "deny", "empty target on recursive delete"
|
|
652
|
+
|
|
653
|
+
# Handle glob-like patterns conservatively
|
|
654
|
+
if any(c in raw for c in ('*', '?', '[', ']')):
|
|
655
|
+
# Check if it is /* or ~/* which are catastrophic
|
|
656
|
+
stripped = raw.rstrip('/')
|
|
657
|
+
if stripped in ('/*', '~/*', './*'):
|
|
658
|
+
return "deny", f"glob expansion at dangerous root: {raw}"
|
|
659
|
+
# Other globs: confirm
|
|
660
|
+
return "ask", f"recursive delete with glob pattern: {raw}"
|
|
661
|
+
|
|
662
|
+
# Resolve path
|
|
663
|
+
try:
|
|
664
|
+
if raw.startswith('~'):
|
|
665
|
+
resolved = (home / raw[2:]).resolve(strict=False) if len(raw) > 1 else home
|
|
666
|
+
elif raw.startswith('/'):
|
|
667
|
+
resolved = Path(raw).resolve(strict=False)
|
|
668
|
+
else:
|
|
669
|
+
resolved = (cwd / raw).resolve(strict=False)
|
|
670
|
+
except (ValueError, OSError):
|
|
671
|
+
return "ask", f"cannot resolve path: {raw}"
|
|
672
|
+
|
|
673
|
+
resolved_str = str(resolved)
|
|
674
|
+
repo_str = str(repo_root)
|
|
675
|
+
home_str = str(home)
|
|
676
|
+
|
|
677
|
+
# Hard-block: root, home, repo root
|
|
678
|
+
if resolved_str == '/':
|
|
679
|
+
return "deny", f"recursive delete targets filesystem root"
|
|
680
|
+
if resolved_str == home_str:
|
|
681
|
+
return "deny", f"recursive delete targets home directory"
|
|
682
|
+
if resolved_str == repo_str:
|
|
683
|
+
return "deny", f"recursive delete targets repository root"
|
|
684
|
+
|
|
685
|
+
# Check if resolved path is a parent of repo or home (even worse)
|
|
686
|
+
if repo_str.startswith(resolved_str + '/'):
|
|
687
|
+
return "deny", f"recursive delete targets ancestor of repository: {raw}"
|
|
688
|
+
if home_str.startswith(resolved_str + '/'):
|
|
689
|
+
return "deny", f"recursive delete targets ancestor of home directory: {raw}"
|
|
690
|
+
|
|
691
|
+
# Allow: known safe prefixes (relative to repo root)
|
|
692
|
+
try:
|
|
693
|
+
rel = resolved.relative_to(repo_root)
|
|
694
|
+
rel_str = str(rel) + '/'
|
|
695
|
+
for prefix in SAFE_DELETE_PREFIXES:
|
|
696
|
+
if rel_str.startswith(prefix):
|
|
697
|
+
return "allow", ""
|
|
698
|
+
except ValueError:
|
|
699
|
+
pass # Not inside repo root
|
|
700
|
+
|
|
701
|
+
# Default: confirm
|
|
702
|
+
return "ask", f"recursive delete: {raw}"
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def classify_find_delete(segment: str) -> tuple[str, str] | None:
|
|
706
|
+
"""Check if segment is a find command with -delete or -exec rm.
|
|
707
|
+
|
|
708
|
+
Returns classification tuple or None if not a find-delete.
|
|
709
|
+
"""
|
|
710
|
+
try:
|
|
711
|
+
tokens = shlex.split(segment)
|
|
712
|
+
except ValueError:
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
cmd = extract_command_word(segment)
|
|
716
|
+
if cmd != 'find':
|
|
717
|
+
return None
|
|
718
|
+
|
|
719
|
+
has_delete = '-delete' in tokens
|
|
720
|
+
has_exec_rm = False
|
|
721
|
+
for i, tok in enumerate(tokens):
|
|
722
|
+
if tok == '-exec' and i + 1 < len(tokens) and 'rm' in tokens[i + 1]:
|
|
723
|
+
has_exec_rm = True
|
|
724
|
+
break
|
|
725
|
+
|
|
726
|
+
if not has_delete and not has_exec_rm:
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
return "ask", f"find with delete action"
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def extract_command_substitutions(raw: str) -> list[str]:
|
|
733
|
+
"""Return the bodies of every $(...) and `...` substitution in raw.
|
|
734
|
+
|
|
735
|
+
Bash executes command substitutions even when they appear inside double
|
|
736
|
+
quotes; only single-quoted regions suppress them. Callers should pass
|
|
737
|
+
each body back through the classifier so a nested `$(rm -rf /)` or
|
|
738
|
+
`$(git reset --hard)` is not treated as inert text.
|
|
739
|
+
|
|
740
|
+
Single-quoted regions, escaped `\\$` and `\\``, and heredoc bodies are
|
|
741
|
+
skipped. Nested `$(...)` is supported by balancing parentheses.
|
|
742
|
+
"""
|
|
743
|
+
bodies: list[str] = []
|
|
744
|
+
i = 0
|
|
745
|
+
in_single = False
|
|
746
|
+
in_heredoc: str | None = None
|
|
747
|
+
|
|
748
|
+
while i < len(raw):
|
|
749
|
+
ch = raw[i]
|
|
750
|
+
|
|
751
|
+
# Heredoc tracking: bodies are inert as far as substitutions go
|
|
752
|
+
# (heredoc expansion is its own surface; classify_command will see
|
|
753
|
+
# the raw text and apply the same rules).
|
|
754
|
+
if in_heredoc is not None:
|
|
755
|
+
nl = raw.find('\n', i)
|
|
756
|
+
if nl < 0:
|
|
757
|
+
break
|
|
758
|
+
line = raw[i:nl]
|
|
759
|
+
i = nl + 1
|
|
760
|
+
if line.strip() == in_heredoc:
|
|
761
|
+
in_heredoc = None
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
if not in_single and raw[i:i+2] == "<<":
|
|
765
|
+
j = i + 2
|
|
766
|
+
while j < len(raw) and raw[j] in (' ', '\t'):
|
|
767
|
+
j += 1
|
|
768
|
+
if j < len(raw) and raw[j] in ("'", '"'):
|
|
769
|
+
j += 1
|
|
770
|
+
k = j
|
|
771
|
+
while k < len(raw) and raw[k] not in (' ', '\t', '\n', "'", '"', ')'):
|
|
772
|
+
k += 1
|
|
773
|
+
if k > j:
|
|
774
|
+
in_heredoc = raw[j:k]
|
|
775
|
+
nl = raw.find('\n', i)
|
|
776
|
+
i = nl + 1 if nl >= 0 else len(raw)
|
|
777
|
+
continue
|
|
778
|
+
|
|
779
|
+
# Escape: `\$`, `\``, and `\\` suppress substitution recognition.
|
|
780
|
+
if ch == '\\' and i + 1 < len(raw):
|
|
781
|
+
i += 2
|
|
782
|
+
continue
|
|
783
|
+
|
|
784
|
+
# Single quotes suppress everything inside; toggle and skip.
|
|
785
|
+
if ch == "'":
|
|
786
|
+
in_single = not in_single
|
|
787
|
+
i += 1
|
|
788
|
+
continue
|
|
789
|
+
|
|
790
|
+
if in_single:
|
|
791
|
+
i += 1
|
|
792
|
+
continue
|
|
793
|
+
|
|
794
|
+
# $(...) substitution — find the matching close paren, respecting
|
|
795
|
+
# nesting and quoted regions inside the body.
|
|
796
|
+
if ch == '$' and i + 1 < len(raw) and raw[i+1] == '(':
|
|
797
|
+
depth = 1
|
|
798
|
+
j = i + 2
|
|
799
|
+
inner_single = False
|
|
800
|
+
inner_double = False
|
|
801
|
+
while j < len(raw) and depth > 0:
|
|
802
|
+
c = raw[j]
|
|
803
|
+
if c == '\\' and j + 1 < len(raw):
|
|
804
|
+
j += 2
|
|
805
|
+
continue
|
|
806
|
+
if not inner_double and c == "'":
|
|
807
|
+
inner_single = not inner_single
|
|
808
|
+
elif not inner_single and c == '"':
|
|
809
|
+
inner_double = not inner_double
|
|
810
|
+
elif not inner_single and not inner_double:
|
|
811
|
+
if c == '(':
|
|
812
|
+
depth += 1
|
|
813
|
+
elif c == ')':
|
|
814
|
+
depth -= 1
|
|
815
|
+
if depth == 0:
|
|
816
|
+
bodies.append(raw[i+2:j])
|
|
817
|
+
j += 1
|
|
818
|
+
break
|
|
819
|
+
j += 1
|
|
820
|
+
i = j
|
|
821
|
+
continue
|
|
822
|
+
|
|
823
|
+
# Backtick substitution. Bash does not support nesting inside the
|
|
824
|
+
# same backtick pair (you need `\``), so a simple scan to the next
|
|
825
|
+
# unescaped backtick is sufficient.
|
|
826
|
+
if ch == '`':
|
|
827
|
+
j = i + 1
|
|
828
|
+
while j < len(raw):
|
|
829
|
+
c = raw[j]
|
|
830
|
+
if c == '\\' and j + 1 < len(raw):
|
|
831
|
+
j += 2
|
|
832
|
+
continue
|
|
833
|
+
if c == '`':
|
|
834
|
+
bodies.append(raw[i+1:j])
|
|
835
|
+
j += 1
|
|
836
|
+
break
|
|
837
|
+
j += 1
|
|
838
|
+
i = j
|
|
839
|
+
continue
|
|
840
|
+
|
|
841
|
+
i += 1
|
|
842
|
+
|
|
843
|
+
return bodies
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def strip_quoted_regions(raw: str) -> str:
|
|
847
|
+
"""Remove content inside single/double quotes and heredocs.
|
|
848
|
+
|
|
849
|
+
Returns only the executable shell surface — quoted literals, heredoc
|
|
850
|
+
bodies, and $(...) subshell content embedded in quotes are replaced
|
|
851
|
+
with whitespace so that regex patterns only match actual commands.
|
|
852
|
+
|
|
853
|
+
Note: command substitutions inside double quotes execute in Bash. This
|
|
854
|
+
helper still blanks them so the surrounding command's literal pattern
|
|
855
|
+
matching is not confused; callers handle substitutions separately via
|
|
856
|
+
extract_command_substitutions().
|
|
857
|
+
"""
|
|
858
|
+
result: list[str] = []
|
|
859
|
+
i = 0
|
|
860
|
+
in_single = False
|
|
861
|
+
in_double = False
|
|
862
|
+
in_heredoc: str | None = None
|
|
863
|
+
|
|
864
|
+
while i < len(raw):
|
|
865
|
+
ch = raw[i]
|
|
866
|
+
|
|
867
|
+
# Heredoc detection (outside quotes)
|
|
868
|
+
if not in_single and not in_double and in_heredoc is None:
|
|
869
|
+
if raw[i:i+2] == "<<":
|
|
870
|
+
j = i + 2
|
|
871
|
+
while j < len(raw) and raw[j] in (' ', '\t'):
|
|
872
|
+
j += 1
|
|
873
|
+
if j < len(raw) and raw[j] in ("'", '"'):
|
|
874
|
+
j += 1
|
|
875
|
+
k = j
|
|
876
|
+
while k < len(raw) and raw[k] not in (' ', '\t', '\n', "'", '"', ')'):
|
|
877
|
+
k += 1
|
|
878
|
+
if k > j:
|
|
879
|
+
in_heredoc = raw[j:k]
|
|
880
|
+
# Keep the << marker but skip to end of line
|
|
881
|
+
result.append(raw[i:i+2])
|
|
882
|
+
nl = raw.find('\n', i)
|
|
883
|
+
if nl >= 0:
|
|
884
|
+
i = nl + 1
|
|
885
|
+
else:
|
|
886
|
+
i = len(raw)
|
|
887
|
+
continue
|
|
888
|
+
|
|
889
|
+
# Inside heredoc: skip until closing marker
|
|
890
|
+
if in_heredoc is not None:
|
|
891
|
+
nl = raw.find('\n', i)
|
|
892
|
+
if nl < 0:
|
|
893
|
+
i = len(raw)
|
|
894
|
+
continue
|
|
895
|
+
line = raw[i:nl]
|
|
896
|
+
i = nl + 1
|
|
897
|
+
if line.strip() == in_heredoc:
|
|
898
|
+
in_heredoc = None
|
|
899
|
+
else:
|
|
900
|
+
result.append(' ') # placeholder
|
|
901
|
+
continue
|
|
902
|
+
|
|
903
|
+
# Escape handling
|
|
904
|
+
if ch == '\\' and not in_single:
|
|
905
|
+
result.append(' ')
|
|
906
|
+
i += 2
|
|
907
|
+
continue
|
|
908
|
+
|
|
909
|
+
# Quote tracking
|
|
910
|
+
if ch == "'" and not in_double:
|
|
911
|
+
if in_single:
|
|
912
|
+
in_single = False
|
|
913
|
+
else:
|
|
914
|
+
in_single = True
|
|
915
|
+
i += 1
|
|
916
|
+
continue
|
|
917
|
+
if ch == '"' and not in_single:
|
|
918
|
+
if in_double:
|
|
919
|
+
in_double = False
|
|
920
|
+
else:
|
|
921
|
+
in_double = True
|
|
922
|
+
i += 1
|
|
923
|
+
continue
|
|
924
|
+
|
|
925
|
+
# Inside quotes: replace with space
|
|
926
|
+
if in_single or in_double:
|
|
927
|
+
result.append(' ')
|
|
928
|
+
i += 1
|
|
929
|
+
continue
|
|
930
|
+
|
|
931
|
+
result.append(ch)
|
|
932
|
+
i += 1
|
|
933
|
+
|
|
934
|
+
return ''.join(result)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# ---------------------------------------------------------------------------
|
|
938
|
+
# Main classifier
|
|
939
|
+
# ---------------------------------------------------------------------------
|
|
940
|
+
|
|
941
|
+
def classify_command(
|
|
942
|
+
raw_command: str,
|
|
943
|
+
repo_root: Path,
|
|
944
|
+
home: Path,
|
|
945
|
+
cwd: Path,
|
|
946
|
+
caws_worktree: bool = False,
|
|
947
|
+
) -> tuple[str, str]:
|
|
948
|
+
"""Classify a full command string.
|
|
949
|
+
|
|
950
|
+
Returns the most restrictive (decision, reason) across all segments.
|
|
951
|
+
Priority: deny > ask > allow.
|
|
952
|
+
"""
|
|
953
|
+
worst_decision = "allow"
|
|
954
|
+
worst_reason = ""
|
|
955
|
+
|
|
956
|
+
def escalate(decision: str, reason: str) -> None:
|
|
957
|
+
nonlocal worst_decision, worst_reason
|
|
958
|
+
priority = {"allow": 0, "ask": 1, "deny": 2}
|
|
959
|
+
if priority.get(decision, 0) > priority.get(worst_decision, 0):
|
|
960
|
+
worst_decision = decision
|
|
961
|
+
worst_reason = reason
|
|
962
|
+
|
|
963
|
+
# --- Pipeline-aware deny patterns ---
|
|
964
|
+
# Strip quoted regions so patterns only match executable shell surface.
|
|
965
|
+
# This prevents commit messages, echo arguments, etc. from triggering.
|
|
966
|
+
executable_surface = strip_quoted_regions(raw_command)
|
|
967
|
+
for pattern, desc in DENY_PIPELINE_PATTERNS:
|
|
968
|
+
if re.search(pattern, executable_surface, re.IGNORECASE):
|
|
969
|
+
escalate("deny", desc)
|
|
970
|
+
|
|
971
|
+
# --- Recursively classify command substitutions ---
|
|
972
|
+
# Bash executes `$(...)` and backtick substitutions even inside double
|
|
973
|
+
# quotes; single-quoted bodies are skipped by extract_command_substitutions.
|
|
974
|
+
# Each extracted body is classified as if it were an independent command.
|
|
975
|
+
for body in extract_command_substitutions(raw_command):
|
|
976
|
+
if not body.strip():
|
|
977
|
+
continue
|
|
978
|
+
sub_decision, sub_reason = classify_command(
|
|
979
|
+
body, repo_root, home, cwd, caws_worktree,
|
|
980
|
+
)
|
|
981
|
+
if sub_decision != "allow":
|
|
982
|
+
escalate(sub_decision, f"command substitution: {sub_reason}")
|
|
983
|
+
|
|
984
|
+
segments = segment_command(raw_command)
|
|
985
|
+
|
|
986
|
+
for segment in segments:
|
|
987
|
+
nested_result = classify_nested_shell(segment, repo_root, home, cwd, caws_worktree)
|
|
988
|
+
if nested_result:
|
|
989
|
+
escalate(*nested_result)
|
|
990
|
+
continue
|
|
991
|
+
|
|
992
|
+
git_result = classify_git_semantics(segment, caws_worktree, repo_root)
|
|
993
|
+
if git_result:
|
|
994
|
+
escalate(*git_result)
|
|
995
|
+
|
|
996
|
+
# Strip quoted regions for pattern matching so that e.g.
|
|
997
|
+
# echo "git reset --hard" does not trigger the git pattern.
|
|
998
|
+
# The original segment is still used for rm/find parsing
|
|
999
|
+
# (shlex.split handles quotes correctly for argument extraction).
|
|
1000
|
+
segment_surface = strip_quoted_regions(segment)
|
|
1001
|
+
|
|
1002
|
+
# --- Hard-block patterns (segment-level) ---
|
|
1003
|
+
for pattern, desc in DENY_SEGMENT_PATTERNS:
|
|
1004
|
+
if re.search(pattern, segment_surface, re.IGNORECASE):
|
|
1005
|
+
escalate("deny", desc)
|
|
1006
|
+
|
|
1007
|
+
# --- Confirm patterns (segment-level) ---
|
|
1008
|
+
for pattern, desc in CONFIRM_SEGMENT_PATTERNS:
|
|
1009
|
+
if re.search(pattern, segment_surface, re.IGNORECASE):
|
|
1010
|
+
# Special case: git init in worktree context is allowed
|
|
1011
|
+
if "git init" in desc and caws_worktree:
|
|
1012
|
+
continue
|
|
1013
|
+
escalate("ask", desc)
|
|
1014
|
+
|
|
1015
|
+
# --- rm classifier ---
|
|
1016
|
+
is_recursive, targets = is_recursive_rm(segment)
|
|
1017
|
+
if is_recursive:
|
|
1018
|
+
if not targets:
|
|
1019
|
+
# Cannot determine targets — be conservative
|
|
1020
|
+
escalate("ask", "recursive delete with unparseable targets")
|
|
1021
|
+
else:
|
|
1022
|
+
for target in targets:
|
|
1023
|
+
decision, reason = classify_rm_target(
|
|
1024
|
+
target, repo_root, home, cwd,
|
|
1025
|
+
)
|
|
1026
|
+
escalate(decision, reason)
|
|
1027
|
+
|
|
1028
|
+
# --- find -delete classifier ---
|
|
1029
|
+
find_result = classify_find_delete(segment)
|
|
1030
|
+
if find_result:
|
|
1031
|
+
escalate(*find_result)
|
|
1032
|
+
|
|
1033
|
+
return worst_decision, worst_reason
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# ---------------------------------------------------------------------------
|
|
1037
|
+
# Entry point
|
|
1038
|
+
# ---------------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
def main() -> None:
|
|
1041
|
+
import argparse
|
|
1042
|
+
|
|
1043
|
+
parser = argparse.ArgumentParser(description="Classify shell command safety")
|
|
1044
|
+
parser.add_argument("--repo-root", default=os.environ.get("CLAUDE_PROJECT_DIR", "."))
|
|
1045
|
+
parser.add_argument("--home", default=str(Path.home()))
|
|
1046
|
+
parser.add_argument("--cwd", default=os.getcwd())
|
|
1047
|
+
args = parser.parse_args()
|
|
1048
|
+
|
|
1049
|
+
raw_command = sys.stdin.read()
|
|
1050
|
+
|
|
1051
|
+
repo_root = Path(args.repo_root).resolve(strict=False)
|
|
1052
|
+
home = Path(args.home).resolve(strict=False)
|
|
1053
|
+
cwd = Path(args.cwd).resolve(strict=False)
|
|
1054
|
+
caws_worktree = has_trusted_git_init_context(repo_root)
|
|
1055
|
+
|
|
1056
|
+
decision, reason = classify_command(
|
|
1057
|
+
raw_command, repo_root, home, cwd, caws_worktree,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
json.dump({"decision": decision, "reason": reason}, sys.stdout)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
if __name__ == "__main__":
|
|
1064
|
+
main()
|