@paths.design/caws-cli 11.0.0 → 11.1.1

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.
Files changed (119) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/init/harness-detect.d.ts +18 -0
  4. package/dist/init/harness-detect.d.ts.map +1 -0
  5. package/dist/init/harness-detect.js +90 -0
  6. package/dist/init/harness-detect.js.map +1 -0
  7. package/dist/init/hook-install.d.ts +53 -0
  8. package/dist/init/hook-install.d.ts.map +1 -0
  9. package/dist/init/hook-install.js +421 -0
  10. package/dist/init/hook-install.js.map +1 -0
  11. package/dist/init/hook-packs/manifest-claude-code.d.ts +4 -0
  12. package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -0
  13. package/dist/init/hook-packs/manifest-claude-code.js +190 -0
  14. package/dist/init/hook-packs/manifest-claude-code.js.map +1 -0
  15. package/dist/init/hook-packs/register.d.ts +19 -0
  16. package/dist/init/hook-packs/register.d.ts.map +1 -0
  17. package/dist/init/hook-packs/register.js +37 -0
  18. package/dist/init/hook-packs/register.js.map +1 -0
  19. package/dist/init/hook-packs/types.d.ts +123 -0
  20. package/dist/init/hook-packs/types.d.ts.map +1 -0
  21. package/dist/init/hook-packs/types.js +29 -0
  22. package/dist/init/hook-packs/types.js.map +1 -0
  23. package/dist/shell/commands/gates.d.ts.map +1 -1
  24. package/dist/shell/commands/gates.js +28 -1
  25. package/dist/shell/commands/gates.js.map +1 -1
  26. package/dist/shell/commands/init.d.ts +9 -0
  27. package/dist/shell/commands/init.d.ts.map +1 -1
  28. package/dist/shell/commands/init.js +131 -27
  29. package/dist/shell/commands/init.js.map +1 -1
  30. package/dist/shell/commands/specs.d.ts +41 -0
  31. package/dist/shell/commands/specs.d.ts.map +1 -0
  32. package/dist/shell/commands/specs.js +264 -0
  33. package/dist/shell/commands/specs.js.map +1 -0
  34. package/dist/shell/commands/worktree.d.ts +38 -0
  35. package/dist/shell/commands/worktree.d.ts.map +1 -0
  36. package/dist/shell/commands/worktree.js +286 -0
  37. package/dist/shell/commands/worktree.js.map +1 -0
  38. package/dist/shell/gates/disposition.d.ts.map +1 -1
  39. package/dist/shell/gates/disposition.js +33 -3
  40. package/dist/shell/gates/disposition.js.map +1 -1
  41. package/dist/shell/gates/local-evaluators/budget-limit.d.ts +24 -0
  42. package/dist/shell/gates/local-evaluators/budget-limit.d.ts.map +1 -0
  43. package/dist/shell/gates/local-evaluators/budget-limit.js +67 -0
  44. package/dist/shell/gates/local-evaluators/budget-limit.js.map +1 -0
  45. package/dist/shell/gates/local-evaluators/diff-helpers.d.ts +25 -0
  46. package/dist/shell/gates/local-evaluators/diff-helpers.d.ts.map +1 -0
  47. package/dist/shell/gates/local-evaluators/diff-helpers.js +74 -0
  48. package/dist/shell/gates/local-evaluators/diff-helpers.js.map +1 -0
  49. package/dist/shell/gates/local-evaluators/index.d.ts +28 -0
  50. package/dist/shell/gates/local-evaluators/index.d.ts.map +1 -0
  51. package/dist/shell/gates/local-evaluators/index.js +67 -0
  52. package/dist/shell/gates/local-evaluators/index.js.map +1 -0
  53. package/dist/shell/gates/local-evaluators/scope-boundary.d.ts +23 -0
  54. package/dist/shell/gates/local-evaluators/scope-boundary.d.ts.map +1 -0
  55. package/dist/shell/gates/local-evaluators/scope-boundary.js +67 -0
  56. package/dist/shell/gates/local-evaluators/scope-boundary.js.map +1 -0
  57. package/dist/shell/gates/local-evaluators/spec-completeness.d.ts +12 -0
  58. package/dist/shell/gates/local-evaluators/spec-completeness.d.ts.map +1 -0
  59. package/dist/shell/gates/local-evaluators/spec-completeness.js +73 -0
  60. package/dist/shell/gates/local-evaluators/spec-completeness.js.map +1 -0
  61. package/dist/shell/index.d.ts +4 -0
  62. package/dist/shell/index.d.ts.map +1 -1
  63. package/dist/shell/index.js +13 -1
  64. package/dist/shell/index.js.map +1 -1
  65. package/dist/shell/register.d.ts.map +1 -1
  66. package/dist/shell/register.js +192 -2
  67. package/dist/shell/register.js.map +1 -1
  68. package/dist/shell/render/init-hook-pack.d.ts +16 -0
  69. package/dist/shell/render/init-hook-pack.d.ts.map +1 -0
  70. package/dist/shell/render/init-hook-pack.js +206 -0
  71. package/dist/shell/render/init-hook-pack.js.map +1 -0
  72. package/dist/store/atomic-write.d.ts +20 -2
  73. package/dist/store/atomic-write.d.ts.map +1 -1
  74. package/dist/store/atomic-write.js +44 -2
  75. package/dist/store/atomic-write.js.map +1 -1
  76. package/dist/store/lifecycle-lock.d.ts +34 -0
  77. package/dist/store/lifecycle-lock.d.ts.map +1 -0
  78. package/dist/store/lifecycle-lock.js +168 -0
  79. package/dist/store/lifecycle-lock.js.map +1 -0
  80. package/dist/store/lifecycle-transaction.d.ts +79 -0
  81. package/dist/store/lifecycle-transaction.d.ts.map +1 -0
  82. package/dist/store/lifecycle-transaction.js +319 -0
  83. package/dist/store/lifecycle-transaction.js.map +1 -0
  84. package/dist/store/rules.d.ts +16 -0
  85. package/dist/store/rules.d.ts.map +1 -1
  86. package/dist/store/rules.js +17 -0
  87. package/dist/store/rules.js.map +1 -1
  88. package/dist/store/specs-writer.d.ts +61 -0
  89. package/dist/store/specs-writer.d.ts.map +1 -0
  90. package/dist/store/specs-writer.js +506 -0
  91. package/dist/store/specs-writer.js.map +1 -0
  92. package/dist/store/worktrees-writer.d.ts +77 -0
  93. package/dist/store/worktrees-writer.d.ts.map +1 -0
  94. package/dist/store/worktrees-writer.js +674 -0
  95. package/dist/store/worktrees-writer.js.map +1 -0
  96. package/dist/store/yaml-patch.d.ts +7 -0
  97. package/dist/store/yaml-patch.d.ts.map +1 -0
  98. package/dist/store/yaml-patch.js +250 -0
  99. package/dist/store/yaml-patch.js.map +1 -0
  100. package/package.json +7 -4
  101. package/templates/hook-packs/claude-code/CLAUDE.md +172 -0
  102. package/templates/hook-packs/claude-code/audit.sh +121 -0
  103. package/templates/hook-packs/claude-code/block-dangerous.sh +158 -0
  104. package/templates/hook-packs/claude-code/classify_command.py +1064 -0
  105. package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +63 -0
  106. package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +50 -0
  107. package/templates/hook-packs/claude-code/dispatch/session_start.sh +41 -0
  108. package/templates/hook-packs/claude-code/dispatch/stop.sh +37 -0
  109. package/templates/hook-packs/claude-code/guard-strikes.sh +140 -0
  110. package/templates/hook-packs/claude-code/lib/parse-input.sh +127 -0
  111. package/templates/hook-packs/claude-code/lib/run-handlers.sh +212 -0
  112. package/templates/hook-packs/claude-code/reset-danger-latch.sh +21 -0
  113. package/templates/hook-packs/claude-code/reset-strikes.sh +243 -0
  114. package/templates/hook-packs/claude-code/runtime-paths.sh +80 -0
  115. package/templates/hook-packs/claude-code/scope-guard.sh +392 -0
  116. package/templates/hook-packs/claude-code/session-caws-status.sh +171 -0
  117. package/templates/hook-packs/claude-code/session-log.sh +180 -0
  118. package/templates/hook-packs/claude-code/worktree-guard.sh +240 -0
  119. 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()