@paths.design/caws-cli 9.3.2 → 10.1.0

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 (286) hide show
  1. package/README.md +71 -32
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/archive.js +67 -28
  4. package/dist/commands/burnup.js +20 -11
  5. package/dist/commands/diagnose.js +34 -22
  6. package/dist/commands/evaluate.js +41 -15
  7. package/dist/commands/gates.js +149 -0
  8. package/dist/commands/init.js +150 -19
  9. package/dist/commands/iterate.js +81 -4
  10. package/dist/commands/parallel.js +4 -0
  11. package/dist/commands/plan.js +9 -19
  12. package/dist/commands/provenance.js +53 -17
  13. package/dist/commands/quality-monitor.js +64 -45
  14. package/dist/commands/scope.js +264 -0
  15. package/dist/commands/sidecar.js +74 -0
  16. package/dist/commands/specs.js +381 -45
  17. package/dist/commands/status.js +117 -9
  18. package/dist/commands/templates.js +0 -8
  19. package/dist/commands/tutorial.js +10 -9
  20. package/dist/commands/validate.js +70 -6
  21. package/dist/commands/verify-acs.js +48 -76
  22. package/dist/commands/waivers.js +212 -13
  23. package/dist/commands/worktree.js +131 -26
  24. package/dist/error-handler.js +2 -13
  25. package/dist/gates/budget-limit.js +121 -0
  26. package/dist/gates/feedback.js +260 -0
  27. package/dist/gates/format.js +179 -0
  28. package/dist/gates/god-object.js +117 -0
  29. package/dist/gates/pipeline.js +167 -0
  30. package/dist/gates/scope-boundary.js +93 -0
  31. package/dist/gates/spec-completeness.js +109 -0
  32. package/dist/gates/todo-detection.js +205 -0
  33. package/dist/index.js +157 -151
  34. package/dist/parallel/parallel-manager.js +3 -3
  35. package/dist/policy/PolicyManager.js +51 -17
  36. package/dist/scaffold/claude-hooks.js +24 -1
  37. package/dist/scaffold/git-hooks.js +45 -102
  38. package/dist/scaffold/index.js +4 -3
  39. package/dist/session/session-manager.js +105 -14
  40. package/dist/sidecars/index.js +33 -0
  41. package/dist/sidecars/listeners.js +40 -0
  42. package/dist/sidecars/provenance-summary.js +238 -0
  43. package/dist/sidecars/quality-gaps.js +258 -0
  44. package/dist/sidecars/schema.js +149 -0
  45. package/dist/sidecars/spec-drift.js +151 -0
  46. package/dist/sidecars/waiver-draft.js +176 -0
  47. package/dist/templates/.caws/schemas/policy.schema.json +112 -0
  48. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  49. package/dist/templates/.caws/schemas/waivers.schema.json +96 -20
  50. package/dist/templates/.caws/schemas/working-spec.schema.json +264 -57
  51. package/dist/templates/.caws/schemas/worktrees.schema.json +3 -1
  52. package/dist/templates/.caws/templates/working-spec.template.yml +10 -4
  53. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  54. package/dist/templates/.claude/README.md +1 -1
  55. package/dist/templates/.claude/hooks/audit.sh +0 -0
  56. package/dist/templates/.claude/hooks/block-dangerous.sh +52 -11
  57. package/dist/templates/.claude/hooks/classify_command.py +592 -0
  58. package/dist/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
  59. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  60. package/dist/templates/.claude/hooks/quality-check.sh +23 -10
  61. package/dist/templates/.claude/hooks/scope-guard.sh +136 -55
  62. package/dist/templates/.claude/hooks/session-caws-status.sh +2 -2
  63. package/dist/templates/.claude/hooks/session-log.sh +76 -3
  64. package/dist/templates/.claude/hooks/stop-worktree-check.sh +1 -1
  65. package/dist/templates/.claude/hooks/test_classify_command.py +370 -0
  66. package/dist/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
  67. package/dist/templates/.claude/hooks/worktree-guard.sh +2 -2
  68. package/dist/templates/.claude/hooks/worktree-write-guard.sh +97 -4
  69. package/dist/templates/.claude/settings.json +31 -0
  70. package/dist/templates/.cursor/hooks/caws-quality-check.sh +4 -4
  71. package/dist/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
  72. package/dist/templates/.cursor/hooks/session-log.sh +924 -0
  73. package/dist/templates/.cursor/hooks.json +25 -0
  74. package/dist/templates/.cursor/rules/02-quality-gates.mdc +3 -5
  75. package/dist/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
  76. package/dist/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
  77. package/dist/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
  78. package/dist/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
  79. package/dist/templates/.github/copilot-instructions.md +5 -5
  80. package/dist/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
  81. package/dist/templates/.junie/guidelines.md +2 -2
  82. package/dist/templates/.vscode/settings.json +3 -1
  83. package/dist/templates/.windsurf/rules/caws-quality-standards.md +2 -2
  84. package/dist/templates/.windsurf/workflows/caws-guided-development.md +3 -3
  85. package/dist/templates/CLAUDE.md +77 -8
  86. package/dist/templates/agents.md +50 -9
  87. package/dist/templates/docs/README.md +8 -7
  88. package/dist/templates/scripts/new_feature.sh +80 -0
  89. package/dist/test-analysis.js +43 -30
  90. package/dist/tool-loader.js +1 -1
  91. package/dist/utils/agent-session.js +202 -0
  92. package/dist/utils/detection.js +8 -2
  93. package/dist/utils/event-log.js +584 -0
  94. package/dist/utils/event-renderer.js +521 -0
  95. package/dist/utils/finalization.js +7 -6
  96. package/dist/utils/gitignore-updater.js +3 -0
  97. package/dist/utils/lifecycle-events.js +94 -0
  98. package/dist/utils/quality-gates-utils.js +29 -44
  99. package/dist/utils/schema-validator.js +50 -0
  100. package/dist/utils/spec-resolver.js +93 -21
  101. package/dist/utils/working-state.js +530 -0
  102. package/dist/validation/spec-validation.js +191 -31
  103. package/dist/waivers-manager.js +144 -6
  104. package/dist/worktree/worktree-manager.js +598 -95
  105. package/package.json +9 -8
  106. package/templates/.caws/schemas/policy.schema.json +112 -0
  107. package/templates/.caws/schemas/scope.schema.json +3 -3
  108. package/templates/.caws/schemas/waivers.schema.json +96 -20
  109. package/templates/.caws/schemas/working-spec.schema.json +264 -57
  110. package/templates/.caws/schemas/worktrees.schema.json +3 -1
  111. package/templates/.caws/templates/working-spec.template.yml +10 -4
  112. package/templates/.caws/tools/scope-guard.js +66 -15
  113. package/templates/.claude/README.md +1 -1
  114. package/templates/.claude/hooks/block-dangerous.sh +52 -11
  115. package/templates/.claude/hooks/classify_command.py +592 -0
  116. package/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
  117. package/templates/.claude/hooks/protected-paths.sh +39 -0
  118. package/templates/.claude/hooks/quality-check.sh +23 -10
  119. package/templates/.claude/hooks/scope-guard.sh +136 -55
  120. package/templates/.claude/hooks/session-caws-status.sh +2 -2
  121. package/templates/.claude/hooks/session-log.sh +76 -3
  122. package/templates/.claude/hooks/stop-worktree-check.sh +1 -1
  123. package/templates/.claude/hooks/test_classify_command.py +370 -0
  124. package/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
  125. package/templates/.claude/hooks/worktree-guard.sh +2 -2
  126. package/templates/.claude/hooks/worktree-write-guard.sh +97 -4
  127. package/templates/.claude/settings.json +31 -0
  128. package/templates/.cursor/hooks/caws-quality-check.sh +4 -4
  129. package/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
  130. package/templates/.cursor/hooks/session-log.sh +924 -0
  131. package/templates/.cursor/hooks.json +25 -0
  132. package/templates/.cursor/rules/02-quality-gates.mdc +3 -5
  133. package/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
  134. package/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
  135. package/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
  136. package/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
  137. package/templates/.github/copilot-instructions.md +5 -5
  138. package/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
  139. package/templates/.junie/guidelines.md +2 -2
  140. package/templates/.vscode/settings.json +3 -1
  141. package/templates/.windsurf/rules/caws-quality-standards.md +2 -2
  142. package/templates/.windsurf/workflows/caws-guided-development.md +3 -3
  143. package/templates/CLAUDE.md +77 -8
  144. package/templates/{AGENTS.md → agents.md} +50 -9
  145. package/templates/docs/README.md +8 -7
  146. package/templates/scripts/new_feature.sh +80 -0
  147. package/dist/budget-derivation.d.ts +0 -74
  148. package/dist/budget-derivation.d.ts.map +0 -1
  149. package/dist/cicd-optimizer.d.ts +0 -142
  150. package/dist/cicd-optimizer.d.ts.map +0 -1
  151. package/dist/commands/archive.d.ts +0 -51
  152. package/dist/commands/archive.d.ts.map +0 -1
  153. package/dist/commands/burnup.d.ts +0 -6
  154. package/dist/commands/burnup.d.ts.map +0 -1
  155. package/dist/commands/diagnose.d.ts +0 -52
  156. package/dist/commands/diagnose.d.ts.map +0 -1
  157. package/dist/commands/evaluate.d.ts +0 -8
  158. package/dist/commands/evaluate.d.ts.map +0 -1
  159. package/dist/commands/init.d.ts +0 -5
  160. package/dist/commands/init.d.ts.map +0 -1
  161. package/dist/commands/iterate.d.ts +0 -8
  162. package/dist/commands/iterate.d.ts.map +0 -1
  163. package/dist/commands/mode.d.ts +0 -25
  164. package/dist/commands/mode.d.ts.map +0 -1
  165. package/dist/commands/parallel.d.ts +0 -7
  166. package/dist/commands/parallel.d.ts.map +0 -1
  167. package/dist/commands/plan.d.ts +0 -49
  168. package/dist/commands/plan.d.ts.map +0 -1
  169. package/dist/commands/provenance.d.ts +0 -32
  170. package/dist/commands/provenance.d.ts.map +0 -1
  171. package/dist/commands/quality-gates.d.ts +0 -6
  172. package/dist/commands/quality-gates.d.ts.map +0 -1
  173. package/dist/commands/quality-gates.js +0 -444
  174. package/dist/commands/quality-monitor.d.ts +0 -17
  175. package/dist/commands/quality-monitor.d.ts.map +0 -1
  176. package/dist/commands/session.d.ts +0 -7
  177. package/dist/commands/session.d.ts.map +0 -1
  178. package/dist/commands/specs.d.ts +0 -77
  179. package/dist/commands/specs.d.ts.map +0 -1
  180. package/dist/commands/status.d.ts +0 -44
  181. package/dist/commands/status.d.ts.map +0 -1
  182. package/dist/commands/templates.d.ts +0 -74
  183. package/dist/commands/templates.d.ts.map +0 -1
  184. package/dist/commands/tool.d.ts +0 -13
  185. package/dist/commands/tool.d.ts.map +0 -1
  186. package/dist/commands/troubleshoot.d.ts +0 -8
  187. package/dist/commands/troubleshoot.d.ts.map +0 -1
  188. package/dist/commands/troubleshoot.js +0 -104
  189. package/dist/commands/tutorial.d.ts +0 -55
  190. package/dist/commands/tutorial.d.ts.map +0 -1
  191. package/dist/commands/validate.d.ts +0 -15
  192. package/dist/commands/validate.d.ts.map +0 -1
  193. package/dist/commands/waivers.d.ts +0 -8
  194. package/dist/commands/waivers.d.ts.map +0 -1
  195. package/dist/commands/workflow.d.ts +0 -85
  196. package/dist/commands/workflow.d.ts.map +0 -1
  197. package/dist/commands/worktree.d.ts +0 -7
  198. package/dist/commands/worktree.d.ts.map +0 -1
  199. package/dist/config/index.d.ts +0 -29
  200. package/dist/config/index.d.ts.map +0 -1
  201. package/dist/config/lite-scope.d.ts +0 -33
  202. package/dist/config/lite-scope.d.ts.map +0 -1
  203. package/dist/config/modes.d.ts +0 -264
  204. package/dist/config/modes.d.ts.map +0 -1
  205. package/dist/constants/spec-types.d.ts +0 -93
  206. package/dist/constants/spec-types.d.ts.map +0 -1
  207. package/dist/error-handler.d.ts +0 -151
  208. package/dist/error-handler.d.ts.map +0 -1
  209. package/dist/generators/jest-config-generator.d.ts +0 -32
  210. package/dist/generators/jest-config-generator.d.ts.map +0 -1
  211. package/dist/generators/jest-config.d.ts +0 -32
  212. package/dist/generators/jest-config.d.ts.map +0 -1
  213. package/dist/generators/jest-config.js +0 -242
  214. package/dist/generators/working-spec.d.ts +0 -13
  215. package/dist/generators/working-spec.d.ts.map +0 -1
  216. package/dist/index-new.d.ts +0 -5
  217. package/dist/index-new.d.ts.map +0 -1
  218. package/dist/index-new.js +0 -317
  219. package/dist/index.d.ts +0 -5
  220. package/dist/index.d.ts.map +0 -1
  221. package/dist/index.js.backup +0 -4711
  222. package/dist/minimal-cli.d.ts +0 -3
  223. package/dist/minimal-cli.d.ts.map +0 -1
  224. package/dist/parallel/parallel-manager.d.ts +0 -67
  225. package/dist/parallel/parallel-manager.d.ts.map +0 -1
  226. package/dist/policy/PolicyManager.d.ts +0 -104
  227. package/dist/policy/PolicyManager.d.ts.map +0 -1
  228. package/dist/scaffold/claude-hooks.d.ts +0 -28
  229. package/dist/scaffold/claude-hooks.d.ts.map +0 -1
  230. package/dist/scaffold/cursor-hooks.d.ts +0 -7
  231. package/dist/scaffold/cursor-hooks.d.ts.map +0 -1
  232. package/dist/scaffold/git-hooks.d.ts +0 -38
  233. package/dist/scaffold/git-hooks.d.ts.map +0 -1
  234. package/dist/scaffold/index.d.ts +0 -17
  235. package/dist/scaffold/index.d.ts.map +0 -1
  236. package/dist/session/session-manager.d.ts +0 -94
  237. package/dist/session/session-manager.d.ts.map +0 -1
  238. package/dist/spec/SpecFileManager.d.ts +0 -146
  239. package/dist/spec/SpecFileManager.d.ts.map +0 -1
  240. package/dist/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
  241. package/dist/templates/.github/copilot/instructions.md +0 -311
  242. package/dist/test-analysis.d.ts +0 -231
  243. package/dist/test-analysis.d.ts.map +0 -1
  244. package/dist/tool-interface.d.ts +0 -236
  245. package/dist/tool-interface.d.ts.map +0 -1
  246. package/dist/tool-loader.d.ts +0 -77
  247. package/dist/tool-loader.d.ts.map +0 -1
  248. package/dist/tool-validator.d.ts +0 -72
  249. package/dist/tool-validator.d.ts.map +0 -1
  250. package/dist/utils/async-utils.d.ts +0 -73
  251. package/dist/utils/async-utils.d.ts.map +0 -1
  252. package/dist/utils/command-wrapper.d.ts +0 -66
  253. package/dist/utils/command-wrapper.d.ts.map +0 -1
  254. package/dist/utils/detection.d.ts +0 -14
  255. package/dist/utils/detection.d.ts.map +0 -1
  256. package/dist/utils/error-categories.d.ts +0 -52
  257. package/dist/utils/error-categories.d.ts.map +0 -1
  258. package/dist/utils/finalization.d.ts +0 -17
  259. package/dist/utils/finalization.d.ts.map +0 -1
  260. package/dist/utils/git-lock.d.ts +0 -13
  261. package/dist/utils/git-lock.d.ts.map +0 -1
  262. package/dist/utils/gitignore-updater.d.ts +0 -39
  263. package/dist/utils/gitignore-updater.d.ts.map +0 -1
  264. package/dist/utils/ide-detection.d.ts +0 -89
  265. package/dist/utils/ide-detection.d.ts.map +0 -1
  266. package/dist/utils/project-analysis.d.ts +0 -34
  267. package/dist/utils/project-analysis.d.ts.map +0 -1
  268. package/dist/utils/promise-utils.d.ts +0 -30
  269. package/dist/utils/promise-utils.d.ts.map +0 -1
  270. package/dist/utils/quality-gates-utils.d.ts +0 -49
  271. package/dist/utils/quality-gates-utils.d.ts.map +0 -1
  272. package/dist/utils/quality-gates.d.ts +0 -49
  273. package/dist/utils/quality-gates.d.ts.map +0 -1
  274. package/dist/utils/quality-gates.js +0 -402
  275. package/dist/utils/spec-resolver.d.ts +0 -80
  276. package/dist/utils/spec-resolver.d.ts.map +0 -1
  277. package/dist/utils/typescript-detector.d.ts +0 -66
  278. package/dist/utils/typescript-detector.d.ts.map +0 -1
  279. package/dist/utils/yaml-validation.d.ts +0 -32
  280. package/dist/utils/yaml-validation.d.ts.map +0 -1
  281. package/dist/validation/spec-validation.d.ts +0 -43
  282. package/dist/validation/spec-validation.d.ts.map +0 -1
  283. package/dist/waivers-manager.d.ts +0 -167
  284. package/dist/waivers-manager.d.ts.map +0 -1
  285. package/dist/worktree/worktree-manager.d.ts +0 -54
  286. package/dist/worktree/worktree-manager.d.ts.map +0 -1
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command safety classifier for Claude Code PreToolUse hooks.
4
+
5
+ Segments shell commands, parses them individually, and classifies each
6
+ as allow / confirm / deny based on tiered policy.
7
+
8
+ Output: JSON object with keys:
9
+ decision: "allow" | "ask" | "deny"
10
+ reason: human-readable explanation (empty string for allow)
11
+
12
+ Usage:
13
+ echo "$COMMAND" | python3 classify_command.py [--repo-root DIR] [--home DIR]
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import shlex
22
+ import sys
23
+ from pathlib import Path
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Configuration
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # Paths that are safe targets for recursive deletion (relative to repo root).
31
+ # After normalization, if the resolved path starts with one of these, allow.
32
+ SAFE_DELETE_PREFIXES: list[str] = [
33
+ "target/",
34
+ "tmp/",
35
+ ".pytest_cache/",
36
+ "node_modules/",
37
+ "__pycache__/",
38
+ ]
39
+
40
+ # Pipeline-aware deny patterns: matched against the FULL raw command string
41
+ # BEFORE segmentation. These detect cross-pipeline dangers like curl|sh and
42
+ # fork bombs whose syntax spans segment boundaries.
43
+ DENY_PIPELINE_PATTERNS: list[tuple[str, str]] = [
44
+ # Pipe-to-shell (network exfiltration) — must match across | boundary
45
+ (r"\b(curl|wget)\b.*\|\s*(ba)?sh\b", "pipe-to-shell execution"),
46
+ # Fork bombs — special syntax that segmentation mangles
47
+ (r":\(\)\s*\{.*:\|:.*\}\s*;\s*:", "fork bomb"),
48
+ (r"\bwhile\s+true\b.*\bfork\b", "fork loop"),
49
+ ]
50
+
51
+ # Segment-level regex patterns that are always hard-blocked.
52
+ # These are matched against individual parsed command segments, NOT the raw
53
+ # command string. Quoted literals in other segments will not trigger them.
54
+ DENY_SEGMENT_PATTERNS: list[tuple[str, str]] = [
55
+ # System destruction
56
+ (r"\bdd\b.*\bif=/dev/(zero|random)\b", "dd with destructive input"),
57
+ (r"\bmkfs\.", "filesystem format"),
58
+ (r"\bfdisk\b", "disk partitioning"),
59
+ (r">\s*/dev/sd", "raw device write"),
60
+ # Permission escalation
61
+ (r"\bchmod\b.*\+s\b", "setuid/setgid bit"),
62
+ # System control
63
+ (r"\b(shutdown|reboot)\b", "system shutdown/reboot"),
64
+ (r"\binit\s+[06]\b", "system runlevel change"),
65
+ ]
66
+
67
+ # Segment-level regex patterns that require user confirmation.
68
+ CONFIRM_SEGMENT_PATTERNS: list[tuple[str, str]] = [
69
+ # Git destructive operations
70
+ (r"\bgit\s+reset\s+--hard\b", "git reset --hard"),
71
+ (r"\bgit\s+push\s+(-f\b|--force\b|--force-with-lease\b)", "git force push"),
72
+ (r"\bgit\s+clean\s+-[a-zA-Z]*f", "git clean with force"),
73
+ (r"\bgit\s+checkout\s+\.\s*$", "git checkout . (discard all changes)"),
74
+ (r"\bgit\s+restore\s+\.\s*$", "git restore . (discard all changes)"),
75
+ (r"\bgit\s+rebase\b", "git rebase (rewrites branch history)"),
76
+ (r"\bgit\s+cherry-pick\b", "git cherry-pick (replays commits across branches)"),
77
+ # chmod 777
78
+ (r"\bchmod\b.*\b777\b", "chmod 777"),
79
+ # History manipulation
80
+ (r"\bhistory\s+-c\b", "history clear"),
81
+ # sudo (not in allowed list)
82
+ (r"^sudo\s+(?!npm|yarn|pnpm|brew|apt-get|apt|dnf|yum)", "sudo command"),
83
+ # venv creation (sprawl prevention)
84
+ (r"\bpython3?\s+-m\s+venv\b", "virtual environment creation"),
85
+ (r"\bvirtualenv\s", "virtual environment creation"),
86
+ (r"\bconda\s+create\b", "conda environment creation"),
87
+ # git init (unless CAWS worktree context)
88
+ (r"\bgit\s+init\b", "git init"),
89
+ # Credential file reads
90
+ (r"\bcat\b.*\.(env|ssh/|aws/)", "credential file read"),
91
+ (r"\bcat\b.*/etc/(passwd|shadow)\b", "system credential read"),
92
+ (r"\bcat\b.*(id_rsa|credentials)\b", "credential file read"),
93
+ ]
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Command segmentation
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def segment_command(raw: str) -> list[str]:
101
+ """Split a shell command string on &&, ||, ;, | operators.
102
+
103
+ Respects quoted strings so that e.g. git commit -m "rm -rf /" does not
104
+ split inside the quotes. Returns individual command segments with
105
+ leading/trailing whitespace stripped.
106
+
107
+ This is intentionally conservative: if we cannot parse, we return
108
+ the entire string as one segment so it still gets classified.
109
+ """
110
+ segments: list[str] = []
111
+ current: list[str] = []
112
+ i = 0
113
+ in_single = False
114
+ in_double = False
115
+ in_heredoc: str | None = None
116
+ heredoc_marker: str = ""
117
+
118
+ while i < len(raw):
119
+ ch = raw[i]
120
+
121
+ # ---- heredoc detection ----
122
+ # Look for <<EOF or <<'EOF' or <<"EOF" at segment level
123
+ if not in_single and not in_double and in_heredoc is None:
124
+ if raw[i:i+2] == "<<":
125
+ # Extract the delimiter
126
+ j = i + 2
127
+ while j < len(raw) and raw[j] in (' ', '\t'):
128
+ j += 1
129
+ # Strip optional quotes around delimiter
130
+ quote_char = None
131
+ if j < len(raw) and raw[j] in ("'", '"'):
132
+ quote_char = raw[j]
133
+ j += 1
134
+ k = j
135
+ while k < len(raw) and raw[k] not in (' ', '\t', '\n', "'", '"', ')'):
136
+ k += 1
137
+ if k > j:
138
+ heredoc_marker = raw[j:k]
139
+ in_heredoc = heredoc_marker
140
+ # Skip to end of this line
141
+ nl = raw.find('\n', i)
142
+ if nl >= 0:
143
+ current.append(raw[i:nl+1])
144
+ i = nl + 1
145
+ else:
146
+ current.append(raw[i:])
147
+ i = len(raw)
148
+ continue
149
+
150
+ # ---- inside heredoc: scan for closing marker ----
151
+ if in_heredoc is not None:
152
+ nl = raw.find('\n', i)
153
+ if nl < 0:
154
+ # No newline found, rest is heredoc content
155
+ current.append(raw[i:])
156
+ i = len(raw)
157
+ continue
158
+ line = raw[i:nl]
159
+ current.append(raw[i:nl+1])
160
+ i = nl + 1
161
+ if line.strip() == in_heredoc:
162
+ in_heredoc = None
163
+ continue
164
+
165
+ # ---- quoting ----
166
+ if ch == '\\' and not in_single:
167
+ current.append(raw[i:i+2])
168
+ i += 2
169
+ continue
170
+ if ch == "'" and not in_double:
171
+ in_single = not in_single
172
+ current.append(ch)
173
+ i += 1
174
+ continue
175
+ if ch == '"' and not in_single:
176
+ in_double = not in_double
177
+ current.append(ch)
178
+ i += 1
179
+ continue
180
+
181
+ # ---- segment separators (only outside quotes) ----
182
+ if not in_single and not in_double:
183
+ # && or ||
184
+ if raw[i:i+2] in ('&&', '||'):
185
+ seg = ''.join(current).strip()
186
+ if seg:
187
+ segments.append(seg)
188
+ current = []
189
+ i += 2
190
+ continue
191
+ # ; (but not ;;)
192
+ if ch == ';' and (i + 1 >= len(raw) or raw[i+1] != ';'):
193
+ seg = ''.join(current).strip()
194
+ if seg:
195
+ segments.append(seg)
196
+ current = []
197
+ i += 1
198
+ continue
199
+ # | (but not ||, already handled above)
200
+ if ch == '|':
201
+ seg = ''.join(current).strip()
202
+ if seg:
203
+ segments.append(seg)
204
+ current = []
205
+ i += 1
206
+ continue
207
+
208
+ current.append(ch)
209
+ i += 1
210
+
211
+ seg = ''.join(current).strip()
212
+ if seg:
213
+ segments.append(seg)
214
+
215
+ return segments if segments else [raw.strip()]
216
+
217
+
218
+ def strip_quotes(s: str) -> str:
219
+ """Remove surrounding quotes from a shell token."""
220
+ if len(s) >= 2:
221
+ if (s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'"):
222
+ return s[1:-1]
223
+ return s
224
+
225
+
226
+ def extract_command_word(segment: str) -> str:
227
+ """Extract the first command word from a segment.
228
+
229
+ Strips leading variable assignments (FOO=bar), env prefixes,
230
+ and common wrappers like 'time'.
231
+ """
232
+ try:
233
+ tokens = shlex.split(segment)
234
+ except ValueError:
235
+ # Malformed quoting — return raw first word
236
+ return segment.split()[0] if segment.split() else ""
237
+
238
+ for tok in tokens:
239
+ # Skip variable assignments
240
+ if '=' in tok and not tok.startswith('-'):
241
+ continue
242
+ # Skip common prefixes
243
+ if tok in ('env', 'time', 'nice', 'nohup', 'command', 'builtin'):
244
+ continue
245
+ return tok
246
+ return ""
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # rm classifier
251
+ # ---------------------------------------------------------------------------
252
+
253
+ def is_recursive_rm(segment: str) -> tuple[bool, list[str]]:
254
+ """Check if a segment is an rm command with recursive flags.
255
+
256
+ Returns (is_recursive, [target_paths]).
257
+ """
258
+ try:
259
+ tokens = shlex.split(segment)
260
+ except ValueError:
261
+ # Cannot parse — be conservative
262
+ if re.search(r'\brm\b', segment) and re.search(r'-[a-zA-Z]*r', segment):
263
+ return True, []
264
+ return False, []
265
+
266
+ if not tokens:
267
+ return False, []
268
+
269
+ # Find the rm command (skip env/time prefixes)
270
+ rm_idx = -1
271
+ for idx, tok in enumerate(tokens):
272
+ if tok in ('env', 'time', 'nice', 'nohup', 'command', 'builtin'):
273
+ continue
274
+ if '=' in tok and not tok.startswith('-'):
275
+ continue
276
+ if tok == 'rm':
277
+ rm_idx = idx
278
+ break
279
+
280
+ if rm_idx < 0:
281
+ return False, []
282
+
283
+ # Check for recursive flag
284
+ is_recursive = False
285
+ targets: list[str] = []
286
+ i = rm_idx + 1
287
+ while i < len(tokens):
288
+ tok = tokens[i]
289
+ if tok == '--':
290
+ # Everything after -- is targets
291
+ targets.extend(tokens[i+1:])
292
+ break
293
+ if tok.startswith('-') and not tok.startswith('--'):
294
+ if 'r' in tok or 'R' in tok:
295
+ is_recursive = True
296
+ elif tok.startswith('--'):
297
+ if tok == '--recursive':
298
+ is_recursive = True
299
+ # Other long options: skip
300
+ else:
301
+ targets.append(tok)
302
+ i += 1
303
+
304
+ return is_recursive, targets
305
+
306
+
307
+ def classify_rm_target(
308
+ target: str,
309
+ repo_root: Path,
310
+ home: Path,
311
+ cwd: Path,
312
+ ) -> tuple[str, str]:
313
+ """Classify a single rm target path.
314
+
315
+ Returns ("deny"|"ask"|"allow", reason).
316
+ """
317
+ # Resolve the target to an absolute path
318
+ raw = target.strip()
319
+ if not raw:
320
+ return "deny", "empty target on recursive delete"
321
+
322
+ # Handle glob-like patterns conservatively
323
+ if any(c in raw for c in ('*', '?', '[', ']')):
324
+ # Check if it is /* or ~/* which are catastrophic
325
+ stripped = raw.rstrip('/')
326
+ if stripped in ('/*', '~/*', './*'):
327
+ return "deny", f"glob expansion at dangerous root: {raw}"
328
+ # Other globs: confirm
329
+ return "ask", f"recursive delete with glob pattern: {raw}"
330
+
331
+ # Resolve path
332
+ try:
333
+ if raw.startswith('~'):
334
+ resolved = (home / raw[2:]).resolve(strict=False) if len(raw) > 1 else home
335
+ elif raw.startswith('/'):
336
+ resolved = Path(raw).resolve(strict=False)
337
+ else:
338
+ resolved = (cwd / raw).resolve(strict=False)
339
+ except (ValueError, OSError):
340
+ return "ask", f"cannot resolve path: {raw}"
341
+
342
+ resolved_str = str(resolved)
343
+ repo_str = str(repo_root)
344
+ home_str = str(home)
345
+
346
+ # Hard-block: root, home, repo root
347
+ if resolved_str == '/':
348
+ return "deny", f"recursive delete targets filesystem root"
349
+ if resolved_str == home_str:
350
+ return "deny", f"recursive delete targets home directory"
351
+ if resolved_str == repo_str:
352
+ return "deny", f"recursive delete targets repository root"
353
+
354
+ # Check if resolved path is a parent of repo or home (even worse)
355
+ if repo_str.startswith(resolved_str + '/'):
356
+ return "deny", f"recursive delete targets ancestor of repository: {raw}"
357
+ if home_str.startswith(resolved_str + '/'):
358
+ return "deny", f"recursive delete targets ancestor of home directory: {raw}"
359
+
360
+ # Allow: known safe prefixes (relative to repo root)
361
+ try:
362
+ rel = resolved.relative_to(repo_root)
363
+ rel_str = str(rel) + '/'
364
+ for prefix in SAFE_DELETE_PREFIXES:
365
+ if rel_str.startswith(prefix):
366
+ return "allow", ""
367
+ except ValueError:
368
+ pass # Not inside repo root
369
+
370
+ # Default: confirm
371
+ return "ask", f"recursive delete: {raw}"
372
+
373
+
374
+ def classify_find_delete(segment: str) -> tuple[str, str] | None:
375
+ """Check if segment is a find command with -delete or -exec rm.
376
+
377
+ Returns classification tuple or None if not a find-delete.
378
+ """
379
+ try:
380
+ tokens = shlex.split(segment)
381
+ except ValueError:
382
+ return None
383
+
384
+ cmd = extract_command_word(segment)
385
+ if cmd != 'find':
386
+ return None
387
+
388
+ has_delete = '-delete' in tokens
389
+ has_exec_rm = False
390
+ for i, tok in enumerate(tokens):
391
+ if tok == '-exec' and i + 1 < len(tokens) and 'rm' in tokens[i + 1]:
392
+ has_exec_rm = True
393
+ break
394
+
395
+ if not has_delete and not has_exec_rm:
396
+ return None
397
+
398
+ return "ask", f"find with delete action"
399
+
400
+
401
+ def strip_quoted_regions(raw: str) -> str:
402
+ """Remove content inside single/double quotes and heredocs.
403
+
404
+ Returns only the executable shell surface — quoted literals, heredoc
405
+ bodies, and $(...) subshell content embedded in quotes are replaced
406
+ with whitespace so that regex patterns only match actual commands.
407
+ """
408
+ result: list[str] = []
409
+ i = 0
410
+ in_single = False
411
+ in_double = False
412
+ in_heredoc: str | None = None
413
+
414
+ while i < len(raw):
415
+ ch = raw[i]
416
+
417
+ # Heredoc detection (outside quotes)
418
+ if not in_single and not in_double and in_heredoc is None:
419
+ if raw[i:i+2] == "<<":
420
+ j = i + 2
421
+ while j < len(raw) and raw[j] in (' ', '\t'):
422
+ j += 1
423
+ if j < len(raw) and raw[j] in ("'", '"'):
424
+ j += 1
425
+ k = j
426
+ while k < len(raw) and raw[k] not in (' ', '\t', '\n', "'", '"', ')'):
427
+ k += 1
428
+ if k > j:
429
+ in_heredoc = raw[j:k]
430
+ # Keep the << marker but skip to end of line
431
+ result.append(raw[i:i+2])
432
+ nl = raw.find('\n', i)
433
+ if nl >= 0:
434
+ i = nl + 1
435
+ else:
436
+ i = len(raw)
437
+ continue
438
+
439
+ # Inside heredoc: skip until closing marker
440
+ if in_heredoc is not None:
441
+ nl = raw.find('\n', i)
442
+ if nl < 0:
443
+ i = len(raw)
444
+ continue
445
+ line = raw[i:nl]
446
+ i = nl + 1
447
+ if line.strip() == in_heredoc:
448
+ in_heredoc = None
449
+ else:
450
+ result.append(' ') # placeholder
451
+ continue
452
+
453
+ # Escape handling
454
+ if ch == '\\' and not in_single:
455
+ result.append(' ')
456
+ i += 2
457
+ continue
458
+
459
+ # Quote tracking
460
+ if ch == "'" and not in_double:
461
+ if in_single:
462
+ in_single = False
463
+ else:
464
+ in_single = True
465
+ i += 1
466
+ continue
467
+ if ch == '"' and not in_single:
468
+ if in_double:
469
+ in_double = False
470
+ else:
471
+ in_double = True
472
+ i += 1
473
+ continue
474
+
475
+ # Inside quotes: replace with space
476
+ if in_single or in_double:
477
+ result.append(' ')
478
+ i += 1
479
+ continue
480
+
481
+ result.append(ch)
482
+ i += 1
483
+
484
+ return ''.join(result)
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Main classifier
489
+ # ---------------------------------------------------------------------------
490
+
491
+ def classify_command(
492
+ raw_command: str,
493
+ repo_root: Path,
494
+ home: Path,
495
+ cwd: Path,
496
+ caws_worktree: bool = False,
497
+ ) -> tuple[str, str]:
498
+ """Classify a full command string.
499
+
500
+ Returns the most restrictive (decision, reason) across all segments.
501
+ Priority: deny > ask > allow.
502
+ """
503
+ worst_decision = "allow"
504
+ worst_reason = ""
505
+
506
+ def escalate(decision: str, reason: str) -> None:
507
+ nonlocal worst_decision, worst_reason
508
+ priority = {"allow": 0, "ask": 1, "deny": 2}
509
+ if priority.get(decision, 0) > priority.get(worst_decision, 0):
510
+ worst_decision = decision
511
+ worst_reason = reason
512
+
513
+ # --- Pipeline-aware deny patterns ---
514
+ # Strip quoted regions so patterns only match executable shell surface.
515
+ # This prevents commit messages, echo arguments, etc. from triggering.
516
+ executable_surface = strip_quoted_regions(raw_command)
517
+ for pattern, desc in DENY_PIPELINE_PATTERNS:
518
+ if re.search(pattern, executable_surface, re.IGNORECASE):
519
+ escalate("deny", desc)
520
+
521
+ segments = segment_command(raw_command)
522
+
523
+ for segment in segments:
524
+ # Strip quoted regions for pattern matching so that e.g.
525
+ # echo "git reset --hard" does not trigger the git pattern.
526
+ # The original segment is still used for rm/find parsing
527
+ # (shlex.split handles quotes correctly for argument extraction).
528
+ segment_surface = strip_quoted_regions(segment)
529
+
530
+ # --- Hard-block patterns (segment-level) ---
531
+ for pattern, desc in DENY_SEGMENT_PATTERNS:
532
+ if re.search(pattern, segment_surface, re.IGNORECASE):
533
+ escalate("deny", desc)
534
+
535
+ # --- Confirm patterns (segment-level) ---
536
+ for pattern, desc in CONFIRM_SEGMENT_PATTERNS:
537
+ if re.search(pattern, segment_surface, re.IGNORECASE):
538
+ # Special case: git init in worktree context is allowed
539
+ if "git init" in desc and caws_worktree:
540
+ continue
541
+ escalate("ask", desc)
542
+
543
+ # --- rm classifier ---
544
+ is_recursive, targets = is_recursive_rm(segment)
545
+ if is_recursive:
546
+ if not targets:
547
+ # Cannot determine targets — be conservative
548
+ escalate("ask", "recursive delete with unparseable targets")
549
+ else:
550
+ for target in targets:
551
+ decision, reason = classify_rm_target(
552
+ target, repo_root, home, cwd,
553
+ )
554
+ escalate(decision, reason)
555
+
556
+ # --- find -delete classifier ---
557
+ find_result = classify_find_delete(segment)
558
+ if find_result:
559
+ escalate(*find_result)
560
+
561
+ return worst_decision, worst_reason
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Entry point
566
+ # ---------------------------------------------------------------------------
567
+
568
+ def main() -> None:
569
+ import argparse
570
+
571
+ parser = argparse.ArgumentParser(description="Classify shell command safety")
572
+ parser.add_argument("--repo-root", default=os.environ.get("CLAUDE_PROJECT_DIR", "."))
573
+ parser.add_argument("--home", default=str(Path.home()))
574
+ parser.add_argument("--cwd", default=os.getcwd())
575
+ args = parser.parse_args()
576
+
577
+ raw_command = sys.stdin.read()
578
+
579
+ repo_root = Path(args.repo_root).resolve(strict=False)
580
+ home = Path(args.home).resolve(strict=False)
581
+ cwd = Path(args.cwd).resolve(strict=False)
582
+ caws_worktree = os.environ.get("CAWS_WORKTREE_CONTEXT", "0") == "1"
583
+
584
+ decision, reason = classify_command(
585
+ raw_command, repo_root, home, cwd, caws_worktree,
586
+ )
587
+
588
+ json.dump({"decision": decision, "reason": reason}, sys.stdout)
589
+
590
+
591
+ if __name__ == "__main__":
592
+ main()