@bahulam/code 2.6.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 (273) hide show
  1. package/README.md +80 -0
  2. package/package.json +49 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +222 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +306 -0
  136. package/src/commands/agent.mjs +220 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +200 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/settings-loader.mjs +45 -0
  143. package/src/config/settings.mjs +132 -0
  144. package/src/context/ast-parser.mjs +298 -0
  145. package/src/context/bm25.mjs +85 -0
  146. package/src/context/retriever.mjs +308 -0
  147. package/src/context/skeleton.mjs +134 -0
  148. package/src/context/symbol-indexer.mjs +375 -0
  149. package/src/core/agent-history.mjs +111 -0
  150. package/src/core/agent-loop.mjs +486 -0
  151. package/src/core/approval-log.mjs +104 -0
  152. package/src/core/approval.mjs +476 -0
  153. package/src/core/attachments.mjs +380 -0
  154. package/src/core/backend-url.mjs +55 -0
  155. package/src/core/cache-control.mjs +92 -0
  156. package/src/core/cache.mjs +105 -0
  157. package/src/core/callback-client.mjs +180 -0
  158. package/src/core/checkpoints.mjs +142 -0
  159. package/src/core/compact-history.mjs +127 -0
  160. package/src/core/context-envelope.mjs +54 -0
  161. package/src/core/context-manager.mjs +198 -0
  162. package/src/core/error-guidance.mjs +311 -0
  163. package/src/core/file-diff.mjs +217 -0
  164. package/src/core/headless.mjs +448 -0
  165. package/src/core/hooks-manager.mjs +87 -0
  166. package/src/core/jsonl-writer.mjs +449 -0
  167. package/src/core/local-agent.mjs +537 -0
  168. package/src/core/local-store.mjs +836 -0
  169. package/src/core/mode-selector.mjs +51 -0
  170. package/src/core/output-filter.mjs +177 -0
  171. package/src/core/paths.mjs +190 -0
  172. package/src/core/policy-resolver.mjs +156 -0
  173. package/src/core/pricing.mjs +336 -0
  174. package/src/core/project-artifacts.mjs +39 -0
  175. package/src/core/project-context-loader.mjs +139 -0
  176. package/src/core/providers.mjs +219 -0
  177. package/src/core/rate-limit-display.mjs +121 -0
  178. package/src/core/rate-limiter.mjs +119 -0
  179. package/src/core/resume-mode.mjs +192 -0
  180. package/src/core/risk-tier.mjs +337 -0
  181. package/src/core/safety.mjs +203 -0
  182. package/src/core/scheduler.mjs +173 -0
  183. package/src/core/session-manager.mjs +360 -0
  184. package/src/core/session.mjs +143 -0
  185. package/src/core/settings-sync.mjs +85 -0
  186. package/src/core/stagnation.mjs +57 -0
  187. package/src/core/stream-client.mjs +829 -0
  188. package/src/core/streaming.mjs +182 -0
  189. package/src/core/system-prompt.mjs +140 -0
  190. package/src/core/tasks.mjs +196 -0
  191. package/src/core/tool-executor.mjs +1950 -0
  192. package/src/core/trust.mjs +158 -0
  193. package/src/core/work-scope.mjs +248 -0
  194. package/src/hooks/engine.mjs +162 -0
  195. package/src/index.mjs +426 -0
  196. package/src/mcp/client.mjs +253 -0
  197. package/src/mcp/transport-shttp.mjs +130 -0
  198. package/src/mcp/transport-sse.mjs +131 -0
  199. package/src/mcp/transport-ws.mjs +134 -0
  200. package/src/onboarding/preflight.mjs +360 -0
  201. package/src/permissions/checker.mjs +57 -0
  202. package/src/permissions/command-classifier.mjs +652 -0
  203. package/src/permissions/injection-check.mjs +60 -0
  204. package/src/permissions/path-check.mjs +102 -0
  205. package/src/permissions/prompt.mjs +73 -0
  206. package/src/permissions/sandbox.mjs +112 -0
  207. package/src/plugins/loader.mjs +138 -0
  208. package/src/skills/installer.mjs +188 -0
  209. package/src/skills/loader.mjs +252 -0
  210. package/src/skills/runner.mjs +55 -0
  211. package/src/state/orbit.mjs +263 -0
  212. package/src/state/verbosity.mjs +99 -0
  213. package/src/telemetry/index.mjs +96 -0
  214. package/src/terminal/agents.mjs +177 -0
  215. package/src/terminal/analytics.mjs +292 -0
  216. package/src/terminal/ansi.mjs +695 -0
  217. package/src/terminal/init.mjs +145 -0
  218. package/src/terminal/main.mjs +269 -0
  219. package/src/terminal/repl-explore.mjs +35 -0
  220. package/src/terminal/repl-format.mjs +257 -0
  221. package/src/terminal/repl-render.mjs +561 -0
  222. package/src/terminal/repl-resume.mjs +625 -0
  223. package/src/terminal/repl-state.mjs +103 -0
  224. package/src/terminal/repl-utils.mjs +34 -0
  225. package/src/terminal/repl.mjs +3832 -0
  226. package/src/terminal/skills.mjs +54 -0
  227. package/src/terminal/tool-display.mjs +240 -0
  228. package/src/tools/agent.mjs +137 -0
  229. package/src/tools/ask-user.mjs +61 -0
  230. package/src/tools/bash.mjs +231 -0
  231. package/src/tools/cron-create.mjs +120 -0
  232. package/src/tools/cron-delete.mjs +49 -0
  233. package/src/tools/cron-list.mjs +37 -0
  234. package/src/tools/edit.mjs +82 -0
  235. package/src/tools/enter-worktree.mjs +69 -0
  236. package/src/tools/exit-worktree.mjs +57 -0
  237. package/src/tools/glob.mjs +117 -0
  238. package/src/tools/grep.mjs +129 -0
  239. package/src/tools/lint.mjs +71 -0
  240. package/src/tools/ls.mjs +58 -0
  241. package/src/tools/lsp.mjs +115 -0
  242. package/src/tools/multi-edit.mjs +94 -0
  243. package/src/tools/notebook-edit.mjs +96 -0
  244. package/src/tools/project-overview.mjs +641 -0
  245. package/src/tools/read-mcp-resource.mjs +57 -0
  246. package/src/tools/read.mjs +138 -0
  247. package/src/tools/registry.mjs +116 -0
  248. package/src/tools/remote-trigger.mjs +84 -0
  249. package/src/tools/send-message.mjs +64 -0
  250. package/src/tools/skill.mjs +52 -0
  251. package/src/tools/test-runner.mjs +49 -0
  252. package/src/tools/todo-write.mjs +68 -0
  253. package/src/tools/tool-search.mjs +77 -0
  254. package/src/tools/web-fetch.mjs +65 -0
  255. package/src/tools/web-search.mjs +89 -0
  256. package/src/tools/write.mjs +55 -0
  257. package/src/ui/approval.mjs +263 -0
  258. package/src/ui/banner.mjs +235 -0
  259. package/src/ui/commands.mjs +537 -0
  260. package/src/ui/formatter.mjs +409 -0
  261. package/src/ui/icons.mjs +164 -0
  262. package/src/ui/input-dock.mjs +444 -0
  263. package/src/ui/markdown.mjs +278 -0
  264. package/src/ui/mission-report.mjs +296 -0
  265. package/src/ui/palette.mjs +189 -0
  266. package/src/ui/slash-commands.mjs +245 -0
  267. package/src/ui/spinner.mjs +116 -0
  268. package/src/ui/sub-agent.mjs +152 -0
  269. package/src/ui/term.mjs +159 -0
  270. package/src/ui/text-layout.mjs +127 -0
  271. package/src/ui/tool-card.mjs +463 -0
  272. package/src/ui/tool-details.mjs +312 -0
  273. package/src/ui/transcript-block.mjs +21 -0
@@ -0,0 +1,652 @@
1
+ /**
2
+ * Command Classifier — port of Claude Code's read-only validation.
3
+ *
4
+ * Classifies shell commands into three categories:
5
+ * - 'safe' → read-only, auto-approved, no sandbox
6
+ * - 'contained' → needs .venv/sandbox (tests, builds, installs)
7
+ * - 'blocked' → denied locally before execution (critical destructive targets, network-exec)
8
+ *
9
+ * Based on openclaude-reference/src/tools/BashTool/readOnlyValidation.ts
10
+ * Ported: COMMAND_ALLOWLIST, flag validation, expansion detection.
11
+ *
12
+ * NOT ported (too complex, diminishing returns):
13
+ * - shell-quote parser (we use regex splitting)
14
+ * - UNC path detection (Windows-specific)
15
+ * - bare git repo detection
16
+ */
17
+
18
+ // ═══════════════════════════════════════════════════════════════════
19
+ // Section 1: Command Splitting
20
+ // ═══════════════════════════════════════════════════════════════════
21
+
22
+ /**
23
+ * Split a compound command into subcommands.
24
+ * Handles &&, ||, ;, and | (pipes).
25
+ * Does NOT handle quoted strings perfectly — known limitation.
26
+ */
27
+ function splitCommand(command) {
28
+ const parts = [];
29
+ let current = '';
30
+ let inSingle = false;
31
+ let inDouble = false;
32
+ let escaped = false;
33
+
34
+ for (let i = 0; i < command.length; i++) {
35
+ const ch = command[i];
36
+
37
+ if (escaped) { current += ch; escaped = false; continue; }
38
+ if (ch === '\\' && !inSingle) { current += ch; escaped = true; continue; }
39
+ if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; }
40
+ if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; }
41
+
42
+ if (!inSingle && !inDouble) {
43
+ if (ch === '&' && command[i + 1] === '&') { parts.push(current); current = ''; i++; continue; }
44
+ if (ch === '|' && command[i + 1] === '|') { parts.push(current); current = ''; i++; continue; }
45
+ if (ch === ';') { parts.push(current); current = ''; continue; }
46
+ if (ch === '|') { parts.push(current); current = ''; continue; }
47
+ }
48
+ current += ch;
49
+ }
50
+ if (current.trim()) parts.push(current);
51
+ return parts.map(p => p.trim()).filter(Boolean);
52
+ }
53
+
54
+ /**
55
+ * Extract the base command name (first word, stripping env vars).
56
+ */
57
+ function extractBaseCommand(command) {
58
+ let cmd = command.trim();
59
+ // Strip leading env var assignments: FOO=bar BAZ=qux command
60
+ while (/^[A-Za-z_][A-Za-z0-9_]*=\S*\s/.test(cmd)) {
61
+ cmd = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, '');
62
+ }
63
+ // Strip common wrapper commands
64
+ for (const wrapper of ['timeout', 'time', 'nice', 'nohup', 'env', 'stdbuf']) {
65
+ const re = new RegExp(`^${wrapper}\\s+(?:-\\S+\\s+)*`);
66
+ if (re.test(cmd)) cmd = cmd.replace(re, '');
67
+ }
68
+ return cmd.split(/\s+/)[0] || '';
69
+ }
70
+
71
+ /**
72
+ * Extract tokens from a command (simple split, respects quotes).
73
+ */
74
+ function tokenize(command) {
75
+ const tokens = [];
76
+ let current = '';
77
+ let inSingle = false;
78
+ let inDouble = false;
79
+ let escaped = false;
80
+
81
+ for (const ch of command) {
82
+ if (escaped) { current += ch; escaped = false; continue; }
83
+ if (ch === '\\' && !inSingle) { escaped = true; current += ch; continue; }
84
+ if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
85
+ if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
86
+ if (ch === ' ' && !inSingle && !inDouble) {
87
+ if (current) tokens.push(current);
88
+ current = '';
89
+ continue;
90
+ }
91
+ current += ch;
92
+ }
93
+ if (current) tokens.push(current);
94
+ return tokens;
95
+ }
96
+
97
+ // ═══════════════════════════════════════════════════════════════════
98
+ // Section 2: Expansion & Injection Detection
99
+ // ═══════════════════════════════════════════════════════════════════
100
+
101
+ /**
102
+ * Detect unquoted glob chars or $VAR expansions that could bypass checks.
103
+ * Ported from readOnlyValidation.ts:containsUnquotedExpansion
104
+ */
105
+ function containsUnquotedExpansion(command) {
106
+ let inSingle = false;
107
+ let inDouble = false;
108
+ let escaped = false;
109
+
110
+ for (let i = 0; i < command.length; i++) {
111
+ const ch = command[i];
112
+ if (escaped) { escaped = false; continue; }
113
+ if (ch === '\\' && !inSingle) { escaped = true; continue; }
114
+ if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
115
+ if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
116
+ if (inSingle) continue;
117
+
118
+ // $VAR expansion (inside double quotes AND unquoted)
119
+ if (ch === '$') {
120
+ const next = command[i + 1];
121
+ if (next && /[A-Za-z_@*#?!$0-9-]/.test(next)) return true;
122
+ if (next === '(' || next === '{') return true;
123
+ }
124
+
125
+ if (inDouble) continue;
126
+ // Glob chars (only dangerous unquoted)
127
+ if (/[?*\[\]]/.test(ch)) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /**
133
+ * Detect command substitution patterns.
134
+ */
135
+ function containsCommandSubstitution(command) {
136
+ // Backticks
137
+ if (/`/.test(command)) return true;
138
+ // $() — but not inside single quotes
139
+ let inSingle = false;
140
+ for (let i = 0; i < command.length; i++) {
141
+ if (command[i] === "'" && (i === 0 || command[i-1] !== '\\')) inSingle = !inSingle;
142
+ if (!inSingle && command[i] === '$' && command[i+1] === '(') return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function isQuotedMessage(text) {
148
+ const value = String(text || '').trim();
149
+ return /^echo\s+(?:"[^"]*"|'[^']*'|[A-Za-z0-9_ .:-]+)$/.test(value);
150
+ }
151
+
152
+ function isNumericKillCommand(command) {
153
+ const trimmed = String(command || '').trim();
154
+ return /^kill\s+(?:-(?:\d+|[A-Z]+)\s+)?\d+(?:\s+\d+)*\s*(?:2>\s*\/dev\/null)?$/.test(trimmed);
155
+ }
156
+
157
+ function isPortLsofKillSubstitution(command) {
158
+ const trimmed = String(command || '').trim();
159
+ return /^kill\s+(?:-(?:\d+|[A-Z]+)\s+)?\$\(\s*lsof\s+-ti:?\d+\s*\)\s*(?:2>\s*\/dev\/null)?$/.test(trimmed);
160
+ }
161
+
162
+ function isPortLsofXargsKill(command) {
163
+ const trimmed = String(command || '').trim();
164
+ return /^lsof\s+-ti:?\d+\s*\|\s*xargs\s+kill(?:\s+-(?:\d+|[A-Z]+))?\s*(?:2>\s*\/dev\/null)?(?:\s*;\s*echo\s+(?:"[^"]*"|'[^']*'|[A-Za-z0-9_ .:-]+))?$/.test(trimmed);
165
+ }
166
+
167
+ function isApprovedProcessCleanupCommand(command) {
168
+ const trimmed = String(command || '').trim();
169
+ if (!trimmed) return false;
170
+ if (isPortLsofXargsKill(trimmed)) return true;
171
+ const segments = splitCommand(trimmed);
172
+ if (segments.length === 0) return false;
173
+ return segments.every(segment => {
174
+ const sub = segment.trim();
175
+ return isNumericKillCommand(sub) ||
176
+ isPortLsofKillSubstitution(sub) ||
177
+ isPortLsofXargsKill(sub) ||
178
+ isQuotedMessage(sub);
179
+ });
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════════════════════════
183
+ // Section 3: Read-Only Command Allowlist
184
+ // ═══════════════════════════════════════════════════════════════════
185
+
186
+ /**
187
+ * Commands that are ALWAYS safe (read-only, no dangerous flags).
188
+ * From readOnlyValidation.ts:READONLY_COMMANDS
189
+ */
190
+ const ALWAYS_SAFE = new Set([
191
+ // File content viewing
192
+ 'cat', 'head', 'tail', 'wc', 'stat', 'strings', 'hexdump', 'od', 'nl',
193
+ // System info
194
+ 'id', 'uname', 'free', 'df', 'du', 'locale', 'groups', 'nproc', 'uptime', 'cal',
195
+ // Path info
196
+ 'basename', 'dirname', 'realpath', 'readlink',
197
+ // Text processing (read-only)
198
+ 'cut', 'paste', 'tr', 'column', 'tac', 'rev', 'fold', 'expand', 'unexpand',
199
+ 'fmt', 'comm', 'cmp', 'numfmt', 'diff',
200
+ // Boolean
201
+ 'true', 'false',
202
+ // Misc safe
203
+ 'sleep', 'which', 'type', 'expr', 'test', 'getconf', 'seq', 'tsort', 'pr',
204
+ // Checksums
205
+ 'sha256sum', 'sha1sum', 'md5sum',
206
+ ]);
207
+
208
+ /** Exact-match safe commands (no args allowed or specific pattern). */
209
+ const EXACT_SAFE = new Set([
210
+ 'pwd', 'whoami', 'arch', 'alias',
211
+ 'node -v', 'node --version',
212
+ 'python --version', 'python3 --version',
213
+ ]);
214
+
215
+ /**
216
+ * Commands with allowed flags — allowlist approach.
217
+ * Key: command name (or "git diff" for multi-word).
218
+ * Value: { flags: { flagName: 'none'|'string'|'number' } }
219
+ *
220
+ * From readOnlyValidation.ts:COMMAND_ALLOWLIST (subset of most important).
221
+ */
222
+ const COMMAND_ALLOWLIST = {
223
+ // ── Git read-only commands ──
224
+ 'git status': { flags: { '-s': 'none', '--short': 'none', '-b': 'none', '--branch': 'none', '--porcelain': 'none', '-u': 'string', '--untracked-files': 'string', '--ignored': 'none', '-z': 'none', '--column': 'string', '--no-column': 'none', '--ahead-behind': 'none', '--no-ahead-behind': 'none', '--renames': 'none', '--no-renames': 'none' } },
225
+ 'git diff': { flags: { '--stat': 'none', '--numstat': 'none', '--shortstat': 'none', '--name-only': 'none', '--name-status': 'none', '--cached': 'none', '--staged': 'none', '-w': 'none', '--ignore-all-space': 'none', '-b': 'none', '--ignore-space-change': 'none', '--no-color': 'none', '--color': 'string', '-U': 'number', '--unified': 'number', '--diff-filter': 'string', '--no-ext-diff': 'none', '--no-renames': 'none', '-R': 'none', '--relative': 'string', '--src-prefix': 'string', '--dst-prefix': 'string' } },
226
+ 'git log': { flags: { '--oneline': 'none', '-n': 'number', '--max-count': 'number', '--pretty': 'string', '--format': 'string', '--graph': 'none', '--all': 'none', '--stat': 'none', '--name-only': 'none', '--name-status': 'none', '--abbrev-commit': 'none', '--no-decorate': 'none', '--decorate': 'string', '--first-parent': 'none', '--merges': 'none', '--no-merges': 'none', '--after': 'string', '--before': 'string', '--since': 'string', '--until': 'string', '--author': 'string', '--grep': 'string', '--follow': 'none', '-p': 'none', '--patch': 'none', '--reverse': 'none', '--topo-order': 'none', '--date-order': 'none', '--date': 'string' } },
227
+ 'git show': { flags: { '--stat': 'none', '--name-only': 'none', '--name-status': 'none', '--pretty': 'string', '--format': 'string', '--no-patch': 'none', '--no-notes': 'none', '-s': 'none', '--abbrev-commit': 'none' } },
228
+ 'git branch': { flags: { '-a': 'none', '--all': 'none', '-r': 'none', '--remotes': 'none', '-v': 'none', '--verbose': 'none', '-vv': 'none', '--list': 'string', '--contains': 'string', '--merged': 'string', '--no-merged': 'string', '--sort': 'string', '--color': 'string', '--no-color': 'none', '--column': 'string', '--no-column': 'none' } },
229
+ 'git blame': { flags: { '-L': 'string', '-w': 'none', '-M': 'none', '-C': 'none', '--no-progress': 'none', '--date': 'string', '-e': 'none', '--show-email': 'none', '-p': 'none', '--porcelain': 'none', '--line-porcelain': 'none' } },
230
+ 'git ls-files': { flags: { '-m': 'none', '--modified': 'none', '-d': 'none', '--deleted': 'none', '-o': 'none', '--others': 'none', '-i': 'none', '--ignored': 'none', '-s': 'none', '--stage': 'none', '-c': 'none', '--cached': 'none', '--exclude-standard': 'none', '-z': 'none', '--full-name': 'none', '--error-unmatch': 'none' } },
231
+ 'git remote': { flags: { '-v': 'none', '--verbose': 'none' } },
232
+ 'git tag': { flags: { '-l': 'string', '--list': 'string', '-n': 'number', '--sort': 'string', '--contains': 'string', '--no-contains': 'string', '--merged': 'string', '--no-merged': 'string', '--column': 'string', '--no-column': 'none', '--color': 'string' } },
233
+ 'git stash list': { flags: { '--pretty': 'string', '--format': 'string', '-n': 'number' } },
234
+ 'git config': { flags: { '--get': 'string', '--get-all': 'string', '--list': 'none', '-l': 'none', '--global': 'none', '--local': 'none', '--system': 'none' } },
235
+ 'git rev-parse': { flags: { '--short': 'none', '--abbrev-ref': 'none', '--show-toplevel': 'none', '--git-dir': 'none', '--is-inside-work-tree': 'none', '--is-bare-repository': 'none', 'HEAD': 'none' } },
236
+
237
+ // ── grep/ripgrep ──
238
+ grep: { flags: { '-r': 'none', '-R': 'none', '--recursive': 'none', '-i': 'none', '--ignore-case': 'none', '-n': 'none', '--line-number': 'none', '-l': 'none', '--files-with-matches': 'none', '-L': 'none', '--files-without-match': 'none', '-c': 'none', '--count': 'none', '-v': 'none', '--invert-match': 'none', '-w': 'none', '--word-regexp': 'none', '-x': 'none', '--line-regexp': 'none', '-E': 'none', '--extended-regexp': 'none', '-F': 'none', '--fixed-strings': 'none', '-P': 'none', '--perl-regexp': 'none', '-o': 'none', '--only-matching': 'none', '-m': 'number', '--max-count': 'number', '-A': 'number', '--after-context': 'number', '-B': 'number', '--before-context': 'number', '-C': 'number', '--context': 'number', '-H': 'none', '--with-filename': 'none', '-h': 'none', '--no-filename': 'none', '--include': 'string', '--exclude': 'string', '--exclude-dir': 'string', '--color': 'string', '-q': 'none', '--quiet': 'none', '-s': 'none', '--no-messages': 'none' } },
239
+ rg: { flags: { '-i': 'none', '--ignore-case': 'none', '-S': 'none', '--smart-case': 'none', '-w': 'none', '--word-regexp': 'none', '-x': 'none', '--line-regexp': 'none', '-c': 'none', '--count': 'none', '-l': 'none', '--files-with-matches': 'none', '--files-without-match': 'none', '-n': 'none', '--line-number': 'none', '-N': 'none', '--no-line-number': 'none', '-H': 'none', '--with-filename': 'none', '--no-filename': 'none', '-o': 'none', '--only-matching': 'none', '-m': 'number', '--max-count': 'number', '-A': 'number', '--after-context': 'number', '-B': 'number', '--before-context': 'number', '-C': 'number', '--context': 'number', '-t': 'string', '--type': 'string', '-T': 'string', '--type-not': 'string', '-g': 'string', '--glob': 'string', '--iglob': 'string', '-F': 'none', '--fixed-strings': 'none', '-P': 'none', '--pcre2': 'none', '-U': 'none', '--multiline': 'none', '--multiline-dotall': 'none', '-u': 'none', '--unrestricted': 'none', '--hidden': 'none', '--no-ignore': 'none', '-L': 'none', '--follow': 'none', '--color': 'string', '--colors': 'string', '-p': 'none', '--pretty': 'none', '-j': 'number', '--threads': 'number', '--max-depth': 'number', '-M': 'number', '--max-columns': 'number', '--sort': 'string', '--sortr': 'string', '-e': 'string', '--regexp': 'string', '--heading': 'none', '--no-heading': 'none', '-q': 'none', '--quiet': 'none', '--trim': 'none', '--vimgrep': 'none', '--json': 'none', '--stats': 'none' } },
240
+
241
+ // ── find (read-only subset — NO -exec, -delete, -fprint) ──
242
+ find: { flags: { '-name': 'string', '-iname': 'string', '-path': 'string', '-ipath': 'string', '-type': 'string', '-maxdepth': 'number', '-mindepth': 'number', '-newer': 'string', '-size': 'string', '-mtime': 'string', '-atime': 'string', '-ctime': 'string', '-perm': 'string', '-user': 'string', '-group': 'string', '-not': 'none', '!': 'none', '-or': 'none', '-o': 'none', '-and': 'none', '-a': 'none', '-print': 'none', '-print0': 'none', '-empty': 'none', '-readable': 'none', '-writable': 'none', '-executable': 'none', '-follow': 'none', '-L': 'none', '-P': 'none', '-H': 'none', '-xdev': 'none', '-mount': 'none', '-daystart': 'none', '-regextype': 'string', '-regex': 'string', '-iregex': 'string' },
243
+ blocked: ['-exec', '-execdir', '-ok', '-okdir', '-delete', '-fprint', '-fprint0', '-fls', '-fprintf'] },
244
+
245
+ // ── sed (read-only subset — NO -i/--in-place) ──
246
+ sed: { flags: { '-n': 'none', '--quiet': 'none', '--silent': 'none', '-E': 'none', '-r': 'none', '--regexp-extended': 'none', '-e': 'string', '--expression': 'string', '-f': 'string', '--file': 'string', '-l': 'number', '--line-length': 'number', '-u': 'none', '--unbuffered': 'none' },
247
+ blocked: ['-i', '--in-place', '-z', '--null-data', '-s', '--separate'] },
248
+
249
+ // ── ls ──
250
+ ls: { flags: { '-l': 'none', '-a': 'none', '-A': 'none', '-h': 'none', '--human-readable': 'none', '-R': 'none', '--recursive': 'none', '-S': 'none', '-t': 'none', '-r': 'none', '--reverse': 'none', '-1': 'none', '-d': 'none', '--directory': 'none', '-F': 'none', '--classify': 'none', '-i': 'none', '--inode': 'none', '-s': 'none', '--size': 'none', '--color': 'string', '--sort': 'string', '--time': 'string', '--group-directories-first': 'none', '-p': 'none', '-G': 'none', '-n': 'none', '--numeric-uid-gid': 'none' } },
251
+
252
+ // ── sort, uniq, jq ──
253
+ sort: { flags: { '-n': 'none', '--numeric-sort': 'none', '-r': 'none', '--reverse': 'none', '-u': 'none', '--unique': 'none', '-k': 'string', '--key': 'string', '-t': 'string', '--field-separator': 'string', '-f': 'none', '--ignore-case': 'none', '-h': 'none', '--human-numeric-sort': 'none', '-V': 'none', '--version-sort': 'none', '-s': 'none', '--stable': 'none', '-c': 'none', '--check': 'none', '-m': 'none', '--merge': 'none' } },
254
+ uniq: { flags: { '-c': 'none', '--count': 'none', '-d': 'none', '--repeated': 'none', '-u': 'none', '--unique': 'none', '-i': 'none', '--ignore-case': 'none', '-f': 'number', '--skip-fields': 'number', '-s': 'number', '--skip-chars': 'number', '-w': 'number', '--check-chars': 'number' } },
255
+
256
+ // ── Process/system inspection ──
257
+ ps: { flags: { '-e': 'none', '-A': 'none', '-a': 'none', '-f': 'none', '-F': 'none', '-l': 'none', '-j': 'none', '-w': 'none', '-ww': 'none', '-H': 'none', '--forest': 'none', '--headers': 'none', '--no-headers': 'none', '--sort': 'string', '-L': 'none', '-T': 'none', '-m': 'none', '-C': 'string', '-p': 'string', '--pid': 'string', '-u': 'string', '--user': 'string', '-t': 'string', '--tty': 'string' } },
258
+ pgrep: { flags: { '-l': 'none', '--list-name': 'none', '-a': 'none', '--list-full': 'none', '-c': 'none', '--count': 'none', '-f': 'none', '--full': 'none', '-i': 'none', '--ignore-case': 'none', '-n': 'none', '--newest': 'none', '-o': 'none', '--oldest': 'none', '-u': 'string', '--euid': 'string', '-x': 'none', '--exact': 'none' } },
259
+ lsof: { flags: { '-i': 'none', '-n': 'none', '-P': 'none', '-t': 'none', '-p': 'string', '-c': 'string', '-u': 'string' } },
260
+ netstat: { flags: { '-a': 'none', '-n': 'none', '-l': 'none', '-t': 'none', '-u': 'none', '-p': 'none', '-r': 'none', '-s': 'none', '-i': 'none', '-g': 'none' } },
261
+
262
+ // ── tree ──
263
+ tree: { flags: { '-a': 'none', '-d': 'none', '-l': 'none', '-f': 'none', '-L': 'number', '-P': 'string', '-I': 'string', '--gitignore': 'none', '-p': 'none', '-u': 'none', '-g': 'none', '-s': 'none', '-h': 'none', '-D': 'none', '-F': 'none', '-n': 'none', '-C': 'none', '-J': 'none', '-X': 'none', '-i': 'none', '-r': 'none', '-t': 'none', '-v': 'none', '--dirsfirst': 'none', '--filesfirst': 'none', '--noreport': 'none', '--charset': 'string', '--filelimit': 'number', '--sort': 'string' },
264
+ blocked: ['-o', '--output'] }, // -o writes to file
265
+
266
+ // ── man, date, hostname, file, base64 ──
267
+ man: { flags: { '-a': 'none', '-f': 'none', '-k': 'none', '-w': 'none', '-S': 'string', '-s': 'string' } },
268
+ date: { flags: { '-d': 'string', '--date': 'string', '-r': 'string', '--reference': 'string', '-u': 'none', '--utc': 'none', '-R': 'none', '-I': 'none', '--iso-8601': 'string', '--rfc-3339': 'string' },
269
+ blocked: ['-s', '--set', '-f', '--file'] },
270
+ file: { flags: { '-b': 'none', '--brief': 'none', '-i': 'none', '--mime': 'none', '--mime-type': 'none', '--mime-encoding': 'none', '-L': 'none', '-h': 'none', '-k': 'none', '-z': 'none' } },
271
+ base64: { flags: { '-d': 'none', '-D': 'none', '--decode': 'none', '-w': 'number', '--wrap': 'number', '-b': 'number', '--break': 'number' } },
272
+ };
273
+
274
+ // ═══════════════════════════════════════════════════════════════════
275
+ // Section 4: Contained Commands (need .venv/sandbox)
276
+ // ═══════════════════════════════════════════════════════════════════
277
+
278
+ /** Commands that modify host state — need contained execution. */
279
+ const CONTAINED_COMMANDS = new Set([
280
+ // Test runners
281
+ 'pytest', 'jest', 'vitest', 'mocha', 'karma',
282
+ 'cargo test', 'go test', 'dotnet test',
283
+ 'npm test', 'npm run test', 'yarn test', 'pnpm test',
284
+ // Build systems
285
+ 'make', 'cmake', 'gradle', 'mvn', 'ant',
286
+ 'npm run build', 'npm run dev', 'npm run start',
287
+ 'yarn build', 'pnpm build',
288
+ 'cargo build', 'cargo run', 'go build', 'go run',
289
+ 'dotnet build', 'dotnet run',
290
+ // Package managers (install)
291
+ 'pip install', 'pip3 install',
292
+ 'npm install', 'npm i', 'npm ci',
293
+ 'yarn install', 'yarn add',
294
+ 'pnpm install', 'pnpm add',
295
+ 'cargo add',
296
+ // Script execution
297
+ 'python', 'python3', 'node', 'deno', 'bun',
298
+ 'ruby', 'perl', 'php',
299
+ // Linters with fix mode
300
+ 'ruff check --fix', 'ruff format',
301
+ 'eslint --fix', 'prettier --write',
302
+ 'black', 'autopep8', 'isort',
303
+ ]);
304
+
305
+ /**
306
+ * Check if a command starts with any contained pattern.
307
+ */
308
+ function isContainedCommand(command) {
309
+ const trimmed = command.trim();
310
+ for (const pattern of CONTAINED_COMMANDS) {
311
+ if (trimmed === pattern || trimmed.startsWith(pattern + ' ') || trimmed.startsWith(pattern + '\t')) {
312
+ return true;
313
+ }
314
+ }
315
+ // python/node with file args (not -c/-e which are inline)
316
+ if (/^(python3?|node)\s+[^-]/.test(trimmed)) return true;
317
+ return false;
318
+ }
319
+
320
+ // ═══════════════════════════════════════════════════════════════════
321
+ // Section 5: Blocked Commands (always denied before execution)
322
+ // ═══════════════════════════════════════════════════════════════════
323
+
324
+ /** Shell patterns that are ALWAYS blocked. */
325
+ const BLOCKED_PATTERNS = [
326
+ /rm\s+(-\w*[rR]\w*\s+)*(?:\/(?:\s|$)|~(?:\s|\/?\s*$)|\$HOME(?:\s|\/?\s*$)|\.\.(?:\s|\/|$)|\.\/\.\.)/, // rm -rf /, ~, $HOME, or ..
327
+ /rm\s+(-\w*[rR]\w*\s+)*\.\s*$/, // rm -rf .
328
+ /rm\s+(-\w*[rR]\w*\s+)*\*\s*$/, // rm -rf *
329
+ /:\(\)\s*\{\s*:\|\s*:\s*&\s*\}\s*;/, // fork bomb
330
+ /mkfs\./, // format filesystem
331
+ /dd\s+.*of=\/dev\//, // disk wipe
332
+ />\s*\/dev\/sd/, // overwrite disk
333
+ /chmod\s+(-\w+\s+)*777\s+\//, // chmod 777 /
334
+ /curl.*\|\s*(ba)?sh/, // pipe curl to shell
335
+ /wget.*\|\s*(ba)?sh/, // pipe wget to shell
336
+ /eval\s*\$\(/, // eval command substitution
337
+ /\bfind\s+\/(?:\s|$)/, // find / scans the whole VM
338
+ /find\s.*-exec/, // find with -exec (code execution)
339
+ /find\s.*-delete/, // find with -delete
340
+ ];
341
+
342
+ /** Commands that need HITL approval even if not blocked. */
343
+ const HIGH_RISK_PATTERNS = [
344
+ /\brm\s/,
345
+ /\bunlink\s/,
346
+ /\brmdir\s/,
347
+ /git\s+push\s+--force/,
348
+ /git\s+reset\s+--hard/,
349
+ /git\s+clean\s+-[fd]/,
350
+ /drop\s+table/i,
351
+ /drop\s+database/i,
352
+ /truncate\s+table/i,
353
+ /sudo\s/,
354
+ /chmod\s/,
355
+ /chown\s/,
356
+ /\bkill\s/,
357
+ /\bpkill\s/,
358
+ /systemctl\s/,
359
+ /launchctl\s/,
360
+ ];
361
+
362
+ // ═══════════════════════════════════════════════════════════════════
363
+ // Section 6: Flag Validation
364
+ // ═══════════════════════════════════════════════════════════════════
365
+
366
+ /**
367
+ * Validate that a command only uses allowed flags from the allowlist.
368
+ * Ported from readOnlyValidation.ts:validateFlags (simplified).
369
+ *
370
+ * @param {string[]} tokens - Tokenized command
371
+ * @param {number} startIdx - Index after the command name tokens
372
+ * @param {object} config - { flags: {...}, blocked?: [...] }
373
+ * @returns {boolean} true if all flags are safe
374
+ */
375
+ function validateFlags(tokens, startIdx, config) {
376
+ const { flags, blocked } = config;
377
+
378
+ for (let i = startIdx; i < tokens.length; i++) {
379
+ const token = tokens[i];
380
+ if (!token) continue;
381
+
382
+ // Reject any token with $ (variable expansion)
383
+ if (token.includes('$')) return false;
384
+
385
+ // Reject brace expansion
386
+ if (token.includes('{') && (token.includes(',') || token.includes('..'))) return false;
387
+
388
+ // Check blocked flags
389
+ if (blocked) {
390
+ for (const b of blocked) {
391
+ if (token === b || token.startsWith(b + '=')) return false;
392
+ if (/^-[A-Za-z]$/.test(b) && token.startsWith(b) && token.length > b.length) return false;
393
+ }
394
+ }
395
+
396
+ if (token.startsWith('--')) {
397
+ // Long flag
398
+ const eqIdx = token.indexOf('=');
399
+ const flagName = eqIdx > 0 ? token.slice(0, eqIdx) : token;
400
+ if (flagName === '--') break; // end of flags
401
+
402
+ if (!(flagName in flags)) return false; // unknown flag
403
+
404
+ const flagType = flags[flagName];
405
+ if (flagType !== 'none' && eqIdx === -1) {
406
+ // Flag needs an argument — consume next token
407
+ i++;
408
+ }
409
+ } else if (token.startsWith('-') && token.length > 1) {
410
+ // Short flag — could be bundled (-abc = -a -b -c)
411
+ // For simplicity, check if exact flag is known
412
+ if (token in flags) {
413
+ const flagType = flags[token];
414
+ if (flagType !== 'none') i++; // consume arg
415
+ } else {
416
+ // Try unbundling: -abc → check each char
417
+ let allKnown = true;
418
+ for (let j = 1; j < token.length; j++) {
419
+ const shortFlag = `-${token[j]}`;
420
+ if (!(shortFlag in flags)) { allKnown = false; break; }
421
+ }
422
+ if (!allKnown) {
423
+ // Check if it's a flag with attached value like -n5
424
+ const shortFlag = `-${token[1]}`;
425
+ if (shortFlag in flags && flags[shortFlag] !== 'none') {
426
+ // -n5 → flag -n with value 5, OK
427
+ } else {
428
+ return false; // unknown flag
429
+ }
430
+ }
431
+ }
432
+ }
433
+ // Positional args are allowed (file paths, patterns, etc.)
434
+ }
435
+
436
+ return true;
437
+ }
438
+
439
+ /**
440
+ * Check if a single command is read-only via the allowlist.
441
+ */
442
+ function isCommandSafeViaAllowlist(command) {
443
+ const trimmed = command.trim();
444
+ const tokens = tokenize(trimmed);
445
+ if (tokens.length === 0) return false;
446
+
447
+ // Check multi-word commands first (e.g., "git diff", "git stash list")
448
+ for (const [cmdPattern, config] of Object.entries(COMMAND_ALLOWLIST)) {
449
+ const cmdTokens = cmdPattern.split(' ');
450
+ if (tokens.length >= cmdTokens.length) {
451
+ let matches = true;
452
+ for (let j = 0; j < cmdTokens.length; j++) {
453
+ if (tokens[j] !== cmdTokens[j]) { matches = false; break; }
454
+ }
455
+ if (matches) {
456
+ return validateFlags(tokens, cmdTokens.length, config);
457
+ }
458
+ }
459
+ }
460
+
461
+ return false;
462
+ }
463
+
464
+ // ═══════════════════════════════════════════════════════════════════
465
+ // Section 7: Main Classifier
466
+ // ═══════════════════════════════════════════════════════════════════
467
+
468
+ /**
469
+ * Command exit code semantics — some commands use non-zero for non-error.
470
+ * Ported from commandSemantics.ts
471
+ */
472
+ const EXIT_CODE_SEMANTICS = {
473
+ grep: (code) => code >= 2, // 1 = no matches (not error)
474
+ rg: (code) => code >= 2,
475
+ find: (code) => code >= 2, // 1 = partial success
476
+ diff: (code) => code >= 2, // 1 = differences found
477
+ test: (code) => code >= 2, // 1 = condition false
478
+ };
479
+
480
+ /**
481
+ * Classify a shell command.
482
+ *
483
+ * @param {string} command - The raw command string
484
+ * @returns {{ classification: 'safe'|'contained'|'blocked', reason: string, highRisk?: boolean }}
485
+ */
486
+ export function classifyCommand(command) {
487
+ if (!command || !command.trim()) {
488
+ return { classification: 'blocked', reason: 'Empty command' };
489
+ }
490
+
491
+ const trimmed = command.trim();
492
+
493
+ if (isApprovedProcessCleanupCommand(trimmed)) {
494
+ return {
495
+ classification: 'contained',
496
+ reason: 'Process cleanup by numeric PID or lsof port lookup; requires approval',
497
+ highRisk: true,
498
+ };
499
+ }
500
+
501
+ // ── Step 1: Check for always-blocked patterns ──
502
+ for (const pattern of BLOCKED_PATTERNS) {
503
+ if (pattern.test(trimmed)) {
504
+ return { classification: 'blocked', reason: `Dangerous pattern: ${trimmed.slice(0, 60)}` };
505
+ }
506
+ }
507
+
508
+ // ── Step 2: Check for command substitution / injection ──
509
+ if (containsCommandSubstitution(trimmed)) {
510
+ return { classification: 'blocked', reason: 'Contains command substitution (backticks or $())' };
511
+ }
512
+
513
+ if (containsUnsafeOutputRedirection(trimmed)) {
514
+ return { classification: 'contained', reason: 'Writes shell output via redirection' };
515
+ }
516
+
517
+ // ── Step 3: Split compound commands and classify each ──
518
+ const subcommands = splitCommand(trimmed);
519
+
520
+ // If any subcommand is blocked, the whole thing is blocked
521
+ // If any subcommand is contained, the whole thing is contained
522
+ // Only safe if ALL subcommands are safe
523
+ let worstLevel = 'safe'; // safe < contained < blocked
524
+
525
+ for (const subcmd of subcommands) {
526
+ const sub = subcmd.trim();
527
+ if (!sub) continue;
528
+
529
+ const subResult = classifySingleCommand(sub);
530
+ if (subResult.classification === 'blocked') {
531
+ return subResult; // immediately blocked
532
+ }
533
+ if (subResult.classification === 'contained' && worstLevel === 'safe') {
534
+ worstLevel = 'contained';
535
+ }
536
+ }
537
+
538
+ // ── Step 4: Check for high-risk patterns (require HITL, then contained execution) ──
539
+ let highRisk = false;
540
+ for (const pattern of HIGH_RISK_PATTERNS) {
541
+ if (pattern.test(trimmed)) {
542
+ highRisk = true;
543
+ // High-risk commands are approved-contained, not hard-blocked.
544
+ // Truly dangerous targets are caught by BLOCKED_PATTERNS above.
545
+ if (worstLevel === 'safe') worstLevel = 'contained';
546
+ break;
547
+ }
548
+ }
549
+
550
+ return {
551
+ classification: worstLevel,
552
+ reason: worstLevel === 'safe' ? 'All subcommands are read-only' : 'Contains write/execute or high-risk operations',
553
+ highRisk,
554
+ };
555
+ }
556
+
557
+ function containsUnsafeOutputRedirection(command) {
558
+ let inSingle = false;
559
+ let inDouble = false;
560
+ let escaped = false;
561
+ for (let i = 0; i < command.length; i++) {
562
+ const ch = command[i];
563
+ if (escaped) { escaped = false; continue; }
564
+ if (ch === '\\' && !inSingle) { escaped = true; continue; }
565
+ if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
566
+ if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
567
+ if (inSingle || inDouble || ch !== '>') continue;
568
+
569
+ const prev = command[i - 1] || '';
570
+ const next = command[i + 1] || '';
571
+ if (next === '&') continue;
572
+ let cursor = next === '>' ? i + 2 : i + 1;
573
+ while (/\s/.test(command[cursor] || '')) cursor++;
574
+ const target = command.slice(cursor).match(/^[^\s;&|]+/)?.[0] || '';
575
+ if (target === '/dev/null') continue;
576
+ if (prev === '2' && target === '/dev/null') continue;
577
+ return true;
578
+ }
579
+ return false;
580
+ }
581
+
582
+ /**
583
+ * Classify a single (non-compound) command.
584
+ */
585
+ function classifySingleCommand(command) {
586
+ const trimmed = command.trim();
587
+ const baseCmd = extractBaseCommand(trimmed);
588
+
589
+ // ── Exact matches ──
590
+ if (EXACT_SAFE.has(trimmed)) {
591
+ return { classification: 'safe', reason: `Exact safe match: ${trimmed}` };
592
+ }
593
+
594
+ // ── Always-safe simple commands ──
595
+ if (ALWAYS_SAFE.has(baseCmd)) {
596
+ // Check for unquoted expansion that could bypass safety
597
+ if (containsUnquotedExpansion(trimmed)) {
598
+ return { classification: 'contained', reason: `${baseCmd} with variable/glob expansion` };
599
+ }
600
+ return { classification: 'safe', reason: `Read-only command: ${baseCmd}` };
601
+ }
602
+
603
+ // ── Allowlist-based flag validation ──
604
+ if (isCommandSafeViaAllowlist(trimmed)) {
605
+ if (containsUnquotedExpansion(trimmed)) {
606
+ return { classification: 'contained', reason: `${baseCmd} with variable/glob expansion` };
607
+ }
608
+ return { classification: 'safe', reason: `Allowlisted with safe flags: ${baseCmd}` };
609
+ }
610
+
611
+ // ── Contained commands (test, build, install, script execution) ──
612
+ if (isContainedCommand(trimmed)) {
613
+ return { classification: 'contained', reason: `Needs contained execution: ${baseCmd}` };
614
+ }
615
+
616
+ // ── Linters without --fix (read-only) ──
617
+ if (/^(ruff check|mypy|pyright|pylint|flake8|tsc --noEmit|eslint(?!\s+--fix))/.test(trimmed)) {
618
+ if (containsUnquotedExpansion(trimmed)) {
619
+ return { classification: 'contained', reason: 'Linter with expansion' };
620
+ }
621
+ return { classification: 'safe', reason: `Read-only linter: ${baseCmd}` };
622
+ }
623
+
624
+ // ── cd (safe but changes state) ──
625
+ if (/^cd(?:\s|$)/.test(trimmed)) {
626
+ if (containsUnquotedExpansion(trimmed)) {
627
+ return { classification: 'contained', reason: 'cd with expansion' };
628
+ }
629
+ return { classification: 'safe', reason: 'Change directory' };
630
+ }
631
+
632
+ // ── echo (safe if no expansion) ──
633
+ if (/^echo\s/.test(trimmed) && !containsUnquotedExpansion(trimmed)) {
634
+ return { classification: 'safe', reason: 'Echo without expansion' };
635
+ }
636
+
637
+ // ── Default: contained (unknown command, not explicitly blocked) ──
638
+ return { classification: 'contained', reason: `Unknown command, defaulting to contained: ${baseCmd}` };
639
+ }
640
+
641
+ /**
642
+ * Check if a command exit code represents an error (semantic-aware).
643
+ */
644
+ export function isExitCodeError(command, exitCode) {
645
+ const baseCmd = extractBaseCommand(command);
646
+ const semantic = EXIT_CODE_SEMANTICS[baseCmd];
647
+ if (semantic) return semantic(exitCode);
648
+ return exitCode !== 0;
649
+ }
650
+
651
+ // Re-export for use in tool-executor
652
+ export { splitCommand, extractBaseCommand, tokenize, containsUnquotedExpansion };