@axplusb/kepler 0.0.1 → 1.0.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 (218) hide show
  1. package/README.md +82 -0
  2. package/package.json +36 -4
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/costs/route.ts +88 -0
  6. package/pulse/app/api/export/route.ts +77 -0
  7. package/pulse/app/api/history/route.ts +11 -0
  8. package/pulse/app/api/import/route.ts +31 -0
  9. package/pulse/app/api/memory/route.ts +52 -0
  10. package/pulse/app/api/plans/route.ts +9 -0
  11. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  12. package/pulse/app/api/projects/route.ts +121 -0
  13. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  14. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  15. package/pulse/app/api/sessions/route.ts +112 -0
  16. package/pulse/app/api/settings/route.ts +14 -0
  17. package/pulse/app/api/stats/route.ts +143 -0
  18. package/pulse/app/api/todos/route.ts +9 -0
  19. package/pulse/app/api/tools/route.ts +160 -0
  20. package/pulse/app/costs/page.tsx +179 -0
  21. package/pulse/app/export/page.tsx +465 -0
  22. package/pulse/app/favicon.ico +0 -0
  23. package/pulse/app/globals.css +263 -0
  24. package/pulse/app/help/page.tsx +142 -0
  25. package/pulse/app/history/page.tsx +157 -0
  26. package/pulse/app/layout.tsx +46 -0
  27. package/pulse/app/memory/page.tsx +365 -0
  28. package/pulse/app/overview-client.tsx +393 -0
  29. package/pulse/app/page.tsx +14 -0
  30. package/pulse/app/plans/page.tsx +308 -0
  31. package/pulse/app/projects/[slug]/page.tsx +390 -0
  32. package/pulse/app/projects/page.tsx +110 -0
  33. package/pulse/app/sessions/[id]/page.tsx +243 -0
  34. package/pulse/app/sessions/page.tsx +39 -0
  35. package/pulse/app/settings/page.tsx +188 -0
  36. package/pulse/app/todos/page.tsx +211 -0
  37. package/pulse/app/tools/page.tsx +249 -0
  38. package/pulse/cli.js +159 -0
  39. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  40. package/pulse/components/activity/streak-card.tsx +36 -0
  41. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  42. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  43. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  44. package/pulse/components/costs/model-token-table.tsx +60 -0
  45. package/pulse/components/global-search.tsx +193 -0
  46. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  47. package/pulse/components/layout/bottom-nav.tsx +52 -0
  48. package/pulse/components/layout/client-layout.tsx +31 -0
  49. package/pulse/components/layout/sidebar-context.tsx +50 -0
  50. package/pulse/components/layout/sidebar.tsx +182 -0
  51. package/pulse/components/layout/top-bar.tsx +121 -0
  52. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  53. package/pulse/components/overview/conversation-table.tsx +148 -0
  54. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  55. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  56. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  57. package/pulse/components/overview/stat-card.tsx +102 -0
  58. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  59. package/pulse/components/projects/project-card.tsx +175 -0
  60. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  61. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  62. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  63. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  64. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  65. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  66. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  67. package/pulse/components/sessions/session-badges.tsx +49 -0
  68. package/pulse/components/sessions/session-table.tsx +299 -0
  69. package/pulse/components/theme-provider.tsx +44 -0
  70. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  71. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  72. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  73. package/pulse/components/tools/version-history-table.tsx +32 -0
  74. package/pulse/components/ui/alert.tsx +66 -0
  75. package/pulse/components/ui/badge.tsx +48 -0
  76. package/pulse/components/ui/breadcrumb.tsx +109 -0
  77. package/pulse/components/ui/button.tsx +64 -0
  78. package/pulse/components/ui/calendar.tsx +220 -0
  79. package/pulse/components/ui/card.tsx +92 -0
  80. package/pulse/components/ui/command.tsx +158 -0
  81. package/pulse/components/ui/dialog.tsx +158 -0
  82. package/pulse/components/ui/input.tsx +21 -0
  83. package/pulse/components/ui/popover.tsx +89 -0
  84. package/pulse/components/ui/progress.tsx +31 -0
  85. package/pulse/components/ui/select.tsx +190 -0
  86. package/pulse/components/ui/separator.tsx +28 -0
  87. package/pulse/components/ui/sheet.tsx +143 -0
  88. package/pulse/components/ui/skeleton.tsx +13 -0
  89. package/pulse/components/ui/table.tsx +116 -0
  90. package/pulse/components/ui/tabs.tsx +91 -0
  91. package/pulse/components/ui/tooltip.tsx +57 -0
  92. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  93. package/pulse/components.json +23 -0
  94. package/pulse/eslint.config.mjs +18 -0
  95. package/pulse/lib/claude-reader.ts +594 -0
  96. package/pulse/lib/decode.ts +129 -0
  97. package/pulse/lib/pricing.ts +102 -0
  98. package/pulse/lib/replay-parser.ts +165 -0
  99. package/pulse/lib/tool-categories.ts +127 -0
  100. package/pulse/lib/utils.ts +6 -0
  101. package/pulse/next-env.d.ts +6 -0
  102. package/pulse/next.config.ts +16 -0
  103. package/pulse/package.json +45 -0
  104. package/pulse/postcss.config.mjs +7 -0
  105. package/pulse/public/activity.png +0 -0
  106. package/pulse/public/cc-lens.png +0 -0
  107. package/pulse/public/command-k.png +0 -0
  108. package/pulse/public/costs.png +0 -0
  109. package/pulse/public/dashboard-dark.png +0 -0
  110. package/pulse/public/dashboard-white.png +0 -0
  111. package/pulse/public/export.png +0 -0
  112. package/pulse/public/file.svg +1 -0
  113. package/pulse/public/globe.svg +1 -0
  114. package/pulse/public/next.svg +1 -0
  115. package/pulse/public/projects.png +0 -0
  116. package/pulse/public/session-chat.png +0 -0
  117. package/pulse/public/todos.png +0 -0
  118. package/pulse/public/tools.png +0 -0
  119. package/pulse/public/vercel.svg +1 -0
  120. package/pulse/public/window.svg +1 -0
  121. package/pulse/tsconfig.json +34 -0
  122. package/pulse/types/claude.ts +294 -0
  123. package/src/agents/loader.mjs +89 -0
  124. package/src/agents/parser.mjs +98 -0
  125. package/src/agents/teams.mjs +123 -0
  126. package/src/auth/oauth.mjs +220 -0
  127. package/src/auth/tarang-auth.mjs +277 -0
  128. package/src/config/cli-args.mjs +173 -0
  129. package/src/config/env.mjs +263 -0
  130. package/src/config/settings.mjs +132 -0
  131. package/src/context/ast-parser.mjs +298 -0
  132. package/src/context/bm25.mjs +85 -0
  133. package/src/context/retriever.mjs +270 -0
  134. package/src/context/skeleton.mjs +134 -0
  135. package/src/core/agent-loop.mjs +480 -0
  136. package/src/core/approval.mjs +273 -0
  137. package/src/core/backend-url.mjs +57 -0
  138. package/src/core/cache.mjs +105 -0
  139. package/src/core/callback-client.mjs +149 -0
  140. package/src/core/checkpoints.mjs +142 -0
  141. package/src/core/context-manager.mjs +198 -0
  142. package/src/core/headless.mjs +168 -0
  143. package/src/core/hooks-manager.mjs +87 -0
  144. package/src/core/jsonl-writer.mjs +351 -0
  145. package/src/core/local-agent.mjs +429 -0
  146. package/src/core/local-store.mjs +325 -0
  147. package/src/core/mode-selector.mjs +51 -0
  148. package/src/core/output-filter.mjs +177 -0
  149. package/src/core/paths.mjs +98 -0
  150. package/src/core/pricing.mjs +314 -0
  151. package/src/core/providers.mjs +219 -0
  152. package/src/core/rate-limiter.mjs +119 -0
  153. package/src/core/safety.mjs +200 -0
  154. package/src/core/scheduler.mjs +173 -0
  155. package/src/core/session-manager.mjs +317 -0
  156. package/src/core/session.mjs +143 -0
  157. package/src/core/settings-sync.mjs +85 -0
  158. package/src/core/stagnation.mjs +57 -0
  159. package/src/core/stream-client.mjs +367 -0
  160. package/src/core/streaming.mjs +182 -0
  161. package/src/core/system-prompt.mjs +135 -0
  162. package/src/core/tool-executor.mjs +725 -0
  163. package/src/hooks/engine.mjs +162 -0
  164. package/src/index.mjs +370 -0
  165. package/src/mcp/client.mjs +253 -0
  166. package/src/mcp/transport-shttp.mjs +130 -0
  167. package/src/mcp/transport-sse.mjs +131 -0
  168. package/src/mcp/transport-ws.mjs +134 -0
  169. package/src/permissions/checker.mjs +57 -0
  170. package/src/permissions/command-classifier.mjs +573 -0
  171. package/src/permissions/injection-check.mjs +60 -0
  172. package/src/permissions/path-check.mjs +102 -0
  173. package/src/permissions/prompt.mjs +73 -0
  174. package/src/permissions/sandbox.mjs +112 -0
  175. package/src/plugins/loader.mjs +138 -0
  176. package/src/skills/loader.mjs +147 -0
  177. package/src/skills/runner.mjs +55 -0
  178. package/src/telemetry/index.mjs +96 -0
  179. package/src/terminal/agents.mjs +177 -0
  180. package/src/terminal/analytics.mjs +292 -0
  181. package/src/terminal/ansi.mjs +421 -0
  182. package/src/terminal/main.mjs +150 -0
  183. package/src/terminal/repl.mjs +1484 -0
  184. package/src/terminal/tool-display.mjs +58 -0
  185. package/src/tools/agent.mjs +137 -0
  186. package/src/tools/ask-user.mjs +61 -0
  187. package/src/tools/bash.mjs +148 -0
  188. package/src/tools/cron-create.mjs +120 -0
  189. package/src/tools/cron-delete.mjs +49 -0
  190. package/src/tools/cron-list.mjs +37 -0
  191. package/src/tools/edit.mjs +82 -0
  192. package/src/tools/enter-worktree.mjs +69 -0
  193. package/src/tools/exit-worktree.mjs +57 -0
  194. package/src/tools/glob.mjs +117 -0
  195. package/src/tools/grep.mjs +129 -0
  196. package/src/tools/lint.mjs +71 -0
  197. package/src/tools/ls.mjs +58 -0
  198. package/src/tools/lsp.mjs +115 -0
  199. package/src/tools/multi-edit.mjs +94 -0
  200. package/src/tools/notebook-edit.mjs +96 -0
  201. package/src/tools/read-mcp-resource.mjs +57 -0
  202. package/src/tools/read.mjs +138 -0
  203. package/src/tools/registry.mjs +132 -0
  204. package/src/tools/remote-trigger.mjs +84 -0
  205. package/src/tools/send-message.mjs +64 -0
  206. package/src/tools/skill.mjs +52 -0
  207. package/src/tools/test-runner.mjs +49 -0
  208. package/src/tools/todo-write.mjs +68 -0
  209. package/src/tools/tool-search.mjs +77 -0
  210. package/src/tools/web-fetch.mjs +65 -0
  211. package/src/tools/web-search.mjs +89 -0
  212. package/src/tools/write.mjs +55 -0
  213. package/src/ui/banner.mjs +237 -0
  214. package/src/ui/commands.mjs +499 -0
  215. package/src/ui/formatter.mjs +379 -0
  216. package/src/ui/markdown.mjs +278 -0
  217. package/src/ui/slash-commands.mjs +258 -0
  218. package/index.js +0 -1
@@ -0,0 +1,573 @@
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' → requires HITL approval (destructive, 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
+ // ═══════════════════════════════════════════════════════════════════
148
+ // Section 3: Read-Only Command Allowlist
149
+ // ═══════════════════════════════════════════════════════════════════
150
+
151
+ /**
152
+ * Commands that are ALWAYS safe (read-only, no dangerous flags).
153
+ * From readOnlyValidation.ts:READONLY_COMMANDS
154
+ */
155
+ const ALWAYS_SAFE = new Set([
156
+ // File content viewing
157
+ 'cat', 'head', 'tail', 'wc', 'stat', 'strings', 'hexdump', 'od', 'nl',
158
+ // System info
159
+ 'id', 'uname', 'free', 'df', 'du', 'locale', 'groups', 'nproc', 'uptime', 'cal',
160
+ // Path info
161
+ 'basename', 'dirname', 'realpath', 'readlink',
162
+ // Text processing (read-only)
163
+ 'cut', 'paste', 'tr', 'column', 'tac', 'rev', 'fold', 'expand', 'unexpand',
164
+ 'fmt', 'comm', 'cmp', 'numfmt', 'diff',
165
+ // Boolean
166
+ 'true', 'false',
167
+ // Misc safe
168
+ 'sleep', 'which', 'type', 'expr', 'test', 'getconf', 'seq', 'tsort', 'pr',
169
+ // Checksums
170
+ 'sha256sum', 'sha1sum', 'md5sum',
171
+ ]);
172
+
173
+ /** Exact-match safe commands (no args allowed or specific pattern). */
174
+ const EXACT_SAFE = new Set([
175
+ 'pwd', 'whoami', 'arch', 'alias',
176
+ 'node -v', 'node --version',
177
+ 'python --version', 'python3 --version',
178
+ ]);
179
+
180
+ /**
181
+ * Commands with allowed flags — allowlist approach.
182
+ * Key: command name (or "git diff" for multi-word).
183
+ * Value: { flags: { flagName: 'none'|'string'|'number' } }
184
+ *
185
+ * From readOnlyValidation.ts:COMMAND_ALLOWLIST (subset of most important).
186
+ */
187
+ const COMMAND_ALLOWLIST = {
188
+ // ── Git read-only commands ──
189
+ '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' } },
190
+ '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' } },
191
+ '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' } },
192
+ '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' } },
193
+ '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' } },
194
+ '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' } },
195
+ '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' } },
196
+ 'git remote': { flags: { '-v': 'none', '--verbose': 'none' } },
197
+ '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' } },
198
+ 'git stash list': { flags: { '--pretty': 'string', '--format': 'string', '-n': 'number' } },
199
+ 'git config': { flags: { '--get': 'string', '--get-all': 'string', '--list': 'none', '-l': 'none', '--global': 'none', '--local': 'none', '--system': 'none' } },
200
+ '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' } },
201
+
202
+ // ── grep/ripgrep ──
203
+ 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' } },
204
+ 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' } },
205
+
206
+ // ── find (read-only subset — NO -exec, -delete, -fprint) ──
207
+ 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' },
208
+ blocked: ['-exec', '-execdir', '-ok', '-okdir', '-delete', '-fprint', '-fprint0', '-fls', '-fprintf'] },
209
+
210
+ // ── ls ──
211
+ 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' } },
212
+
213
+ // ── sort, uniq, jq ──
214
+ 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' } },
215
+ 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' } },
216
+
217
+ // ── Process/system inspection ──
218
+ 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' } },
219
+ 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' } },
220
+ lsof: { flags: { '-i': 'none', '-n': 'none', '-P': 'none', '-t': 'none', '-p': 'string', '-c': 'string', '-u': 'string' } },
221
+ netstat: { flags: { '-a': 'none', '-n': 'none', '-l': 'none', '-t': 'none', '-u': 'none', '-p': 'none', '-r': 'none', '-s': 'none', '-i': 'none', '-g': 'none' } },
222
+
223
+ // ── tree ──
224
+ 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' },
225
+ blocked: ['-o', '--output'] }, // -o writes to file
226
+
227
+ // ── man, date, hostname, file, base64 ──
228
+ man: { flags: { '-a': 'none', '-f': 'none', '-k': 'none', '-w': 'none', '-S': 'string', '-s': 'string' } },
229
+ date: { flags: { '-d': 'string', '--date': 'string', '-r': 'string', '--reference': 'string', '-u': 'none', '--utc': 'none', '-R': 'none', '-I': 'none', '--iso-8601': 'string', '--rfc-3339': 'string' },
230
+ blocked: ['-s', '--set', '-f', '--file'] },
231
+ file: { flags: { '-b': 'none', '--brief': 'none', '-i': 'none', '--mime': 'none', '--mime-type': 'none', '--mime-encoding': 'none', '-L': 'none', '-h': 'none', '-k': 'none', '-z': 'none' } },
232
+ base64: { flags: { '-d': 'none', '-D': 'none', '--decode': 'none', '-w': 'number', '--wrap': 'number', '-b': 'number', '--break': 'number' } },
233
+ };
234
+
235
+ // ═══════════════════════════════════════════════════════════════════
236
+ // Section 4: Contained Commands (need .venv/sandbox)
237
+ // ═══════════════════════════════════════════════════════════════════
238
+
239
+ /** Commands that modify host state — need contained execution. */
240
+ const CONTAINED_COMMANDS = new Set([
241
+ // Test runners
242
+ 'pytest', 'jest', 'vitest', 'mocha', 'karma',
243
+ 'cargo test', 'go test', 'dotnet test',
244
+ 'npm test', 'npm run test', 'yarn test', 'pnpm test',
245
+ // Build systems
246
+ 'make', 'cmake', 'gradle', 'mvn', 'ant',
247
+ 'npm run build', 'npm run dev', 'npm run start',
248
+ 'yarn build', 'pnpm build',
249
+ 'cargo build', 'cargo run', 'go build', 'go run',
250
+ 'dotnet build', 'dotnet run',
251
+ // Package managers (install)
252
+ 'pip install', 'pip3 install',
253
+ 'npm install', 'npm i', 'npm ci',
254
+ 'yarn install', 'yarn add',
255
+ 'pnpm install', 'pnpm add',
256
+ 'cargo add',
257
+ // Script execution
258
+ 'python', 'python3', 'node', 'deno', 'bun',
259
+ 'ruby', 'perl', 'php',
260
+ // Linters with fix mode
261
+ 'ruff check --fix', 'ruff format',
262
+ 'eslint --fix', 'prettier --write',
263
+ 'black', 'autopep8', 'isort',
264
+ ]);
265
+
266
+ /**
267
+ * Check if a command starts with any contained pattern.
268
+ */
269
+ function isContainedCommand(command) {
270
+ const trimmed = command.trim();
271
+ for (const pattern of CONTAINED_COMMANDS) {
272
+ if (trimmed === pattern || trimmed.startsWith(pattern + ' ') || trimmed.startsWith(pattern + '\t')) {
273
+ return true;
274
+ }
275
+ }
276
+ // python/node with file args (not -c/-e which are inline)
277
+ if (/^(python3?|node)\s+[^-]/.test(trimmed)) return true;
278
+ return false;
279
+ }
280
+
281
+ // ═══════════════════════════════════════════════════════════════════
282
+ // Section 5: Blocked Commands (always need HITL approval)
283
+ // ═══════════════════════════════════════════════════════════════════
284
+
285
+ /** Shell patterns that are ALWAYS blocked. */
286
+ const BLOCKED_PATTERNS = [
287
+ /rm\s+(-\w*[rR]\w*\s+)*(\/|~|\$HOME|\.\.|\.\/\.\.)/, // rm -rf / or ~ or ..
288
+ /rm\s+(-\w*[rR]\w*\s+)*\.\s*$/, // rm -rf .
289
+ /rm\s+(-\w*[rR]\w*\s+)*\*\s*$/, // rm -rf *
290
+ /:\(\)\s*\{\s*:\|\s*:\s*&\s*\}\s*;/, // fork bomb
291
+ /mkfs\./, // format filesystem
292
+ /dd\s+.*of=\/dev\//, // disk wipe
293
+ />\s*\/dev\/sd/, // overwrite disk
294
+ /chmod\s+(-\w+\s+)*777\s+\//, // chmod 777 /
295
+ /curl.*\|\s*(ba)?sh/, // pipe curl to shell
296
+ /wget.*\|\s*(ba)?sh/, // pipe wget to shell
297
+ /eval\s*\$\(/, // eval command substitution
298
+ /find\s.*-exec/, // find with -exec (code execution)
299
+ /find\s.*-delete/, // find with -delete
300
+ ];
301
+
302
+ /** Commands that need HITL approval even if not blocked. */
303
+ const HIGH_RISK_PATTERNS = [
304
+ /\brm\s/,
305
+ /\bunlink\s/,
306
+ /\brmdir\s/,
307
+ /git\s+push\s+--force/,
308
+ /git\s+reset\s+--hard/,
309
+ /git\s+clean\s+-[fd]/,
310
+ /drop\s+table/i,
311
+ /drop\s+database/i,
312
+ /truncate\s+table/i,
313
+ /sudo\s/,
314
+ /chmod\s/,
315
+ /chown\s/,
316
+ /\bkill\s/,
317
+ /\bpkill\s/,
318
+ /systemctl\s/,
319
+ /launchctl\s/,
320
+ ];
321
+
322
+ // ═══════════════════════════════════════════════════════════════════
323
+ // Section 6: Flag Validation
324
+ // ═══════════════════════════════════════════════════════════════════
325
+
326
+ /**
327
+ * Validate that a command only uses allowed flags from the allowlist.
328
+ * Ported from readOnlyValidation.ts:validateFlags (simplified).
329
+ *
330
+ * @param {string[]} tokens - Tokenized command
331
+ * @param {number} startIdx - Index after the command name tokens
332
+ * @param {object} config - { flags: {...}, blocked?: [...] }
333
+ * @returns {boolean} true if all flags are safe
334
+ */
335
+ function validateFlags(tokens, startIdx, config) {
336
+ const { flags, blocked } = config;
337
+
338
+ for (let i = startIdx; i < tokens.length; i++) {
339
+ const token = tokens[i];
340
+ if (!token) continue;
341
+
342
+ // Reject any token with $ (variable expansion)
343
+ if (token.includes('$')) return false;
344
+
345
+ // Reject brace expansion
346
+ if (token.includes('{') && (token.includes(',') || token.includes('..'))) return false;
347
+
348
+ // Check blocked flags
349
+ if (blocked) {
350
+ for (const b of blocked) {
351
+ if (token === b || token.startsWith(b + '=')) return false;
352
+ }
353
+ }
354
+
355
+ if (token.startsWith('--')) {
356
+ // Long flag
357
+ const eqIdx = token.indexOf('=');
358
+ const flagName = eqIdx > 0 ? token.slice(0, eqIdx) : token;
359
+ if (flagName === '--') break; // end of flags
360
+
361
+ if (!(flagName in flags)) return false; // unknown flag
362
+
363
+ const flagType = flags[flagName];
364
+ if (flagType !== 'none' && eqIdx === -1) {
365
+ // Flag needs an argument — consume next token
366
+ i++;
367
+ }
368
+ } else if (token.startsWith('-') && token.length > 1) {
369
+ // Short flag — could be bundled (-abc = -a -b -c)
370
+ // For simplicity, check if exact flag is known
371
+ if (token in flags) {
372
+ const flagType = flags[token];
373
+ if (flagType !== 'none') i++; // consume arg
374
+ } else {
375
+ // Try unbundling: -abc → check each char
376
+ let allKnown = true;
377
+ for (let j = 1; j < token.length; j++) {
378
+ const shortFlag = `-${token[j]}`;
379
+ if (!(shortFlag in flags)) { allKnown = false; break; }
380
+ }
381
+ if (!allKnown) {
382
+ // Check if it's a flag with attached value like -n5
383
+ const shortFlag = `-${token[1]}`;
384
+ if (shortFlag in flags && flags[shortFlag] !== 'none') {
385
+ // -n5 → flag -n with value 5, OK
386
+ } else {
387
+ return false; // unknown flag
388
+ }
389
+ }
390
+ }
391
+ }
392
+ // Positional args are allowed (file paths, patterns, etc.)
393
+ }
394
+
395
+ return true;
396
+ }
397
+
398
+ /**
399
+ * Check if a single command is read-only via the allowlist.
400
+ */
401
+ function isCommandSafeViaAllowlist(command) {
402
+ const trimmed = command.trim();
403
+ const tokens = tokenize(trimmed);
404
+ if (tokens.length === 0) return false;
405
+
406
+ // Check multi-word commands first (e.g., "git diff", "git stash list")
407
+ for (const [cmdPattern, config] of Object.entries(COMMAND_ALLOWLIST)) {
408
+ const cmdTokens = cmdPattern.split(' ');
409
+ if (tokens.length >= cmdTokens.length) {
410
+ let matches = true;
411
+ for (let j = 0; j < cmdTokens.length; j++) {
412
+ if (tokens[j] !== cmdTokens[j]) { matches = false; break; }
413
+ }
414
+ if (matches) {
415
+ return validateFlags(tokens, cmdTokens.length, config);
416
+ }
417
+ }
418
+ }
419
+
420
+ return false;
421
+ }
422
+
423
+ // ═══════════════════════════════════════════════════════════════════
424
+ // Section 7: Main Classifier
425
+ // ═══════════════════════════════════════════════════════════════════
426
+
427
+ /**
428
+ * Command exit code semantics — some commands use non-zero for non-error.
429
+ * Ported from commandSemantics.ts
430
+ */
431
+ const EXIT_CODE_SEMANTICS = {
432
+ grep: (code) => code >= 2, // 1 = no matches (not error)
433
+ rg: (code) => code >= 2,
434
+ find: (code) => code >= 2, // 1 = partial success
435
+ diff: (code) => code >= 2, // 1 = differences found
436
+ test: (code) => code >= 2, // 1 = condition false
437
+ };
438
+
439
+ /**
440
+ * Classify a shell command.
441
+ *
442
+ * @param {string} command - The raw command string
443
+ * @returns {{ classification: 'safe'|'contained'|'blocked', reason: string, highRisk?: boolean }}
444
+ */
445
+ export function classifyCommand(command) {
446
+ if (!command || !command.trim()) {
447
+ return { classification: 'blocked', reason: 'Empty command' };
448
+ }
449
+
450
+ const trimmed = command.trim();
451
+
452
+ // ── Step 1: Check for always-blocked patterns ──
453
+ for (const pattern of BLOCKED_PATTERNS) {
454
+ if (pattern.test(trimmed)) {
455
+ return { classification: 'blocked', reason: `Dangerous pattern: ${trimmed.slice(0, 60)}` };
456
+ }
457
+ }
458
+
459
+ // ── Step 2: Check for command substitution / injection ──
460
+ if (containsCommandSubstitution(trimmed)) {
461
+ return { classification: 'blocked', reason: 'Contains command substitution (backticks or $())' };
462
+ }
463
+
464
+ // ── Step 3: Split compound commands and classify each ──
465
+ const subcommands = splitCommand(trimmed);
466
+
467
+ // If any subcommand is blocked, the whole thing is blocked
468
+ // If any subcommand is contained, the whole thing is contained
469
+ // Only safe if ALL subcommands are safe
470
+ let worstLevel = 'safe'; // safe < contained < blocked
471
+
472
+ for (const subcmd of subcommands) {
473
+ const sub = subcmd.trim();
474
+ if (!sub) continue;
475
+
476
+ const subResult = classifySingleCommand(sub);
477
+ if (subResult.classification === 'blocked') {
478
+ return subResult; // immediately blocked
479
+ }
480
+ if (subResult.classification === 'contained' && worstLevel === 'safe') {
481
+ worstLevel = 'contained';
482
+ }
483
+ }
484
+
485
+ // ── Step 4: Check for high-risk patterns (escalate to blocked) ──
486
+ let highRisk = false;
487
+ for (const pattern of HIGH_RISK_PATTERNS) {
488
+ if (pattern.test(trimmed)) {
489
+ highRisk = true;
490
+ // High-risk patterns escalate contained → blocked
491
+ if (worstLevel !== 'blocked') worstLevel = 'blocked';
492
+ break;
493
+ }
494
+ }
495
+
496
+ return {
497
+ classification: worstLevel,
498
+ reason: worstLevel === 'safe' ? 'All subcommands are read-only' : 'Contains write/execute or high-risk operations',
499
+ highRisk,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Classify a single (non-compound) command.
505
+ */
506
+ function classifySingleCommand(command) {
507
+ const trimmed = command.trim();
508
+ const baseCmd = extractBaseCommand(trimmed);
509
+
510
+ // ── Exact matches ──
511
+ if (EXACT_SAFE.has(trimmed)) {
512
+ return { classification: 'safe', reason: `Exact safe match: ${trimmed}` };
513
+ }
514
+
515
+ // ── Always-safe simple commands ──
516
+ if (ALWAYS_SAFE.has(baseCmd)) {
517
+ // Check for unquoted expansion that could bypass safety
518
+ if (containsUnquotedExpansion(trimmed)) {
519
+ return { classification: 'contained', reason: `${baseCmd} with variable/glob expansion` };
520
+ }
521
+ return { classification: 'safe', reason: `Read-only command: ${baseCmd}` };
522
+ }
523
+
524
+ // ── Allowlist-based flag validation ──
525
+ if (isCommandSafeViaAllowlist(trimmed)) {
526
+ if (containsUnquotedExpansion(trimmed)) {
527
+ return { classification: 'contained', reason: `${baseCmd} with variable/glob expansion` };
528
+ }
529
+ return { classification: 'safe', reason: `Allowlisted with safe flags: ${baseCmd}` };
530
+ }
531
+
532
+ // ── Contained commands (test, build, install, script execution) ──
533
+ if (isContainedCommand(trimmed)) {
534
+ return { classification: 'contained', reason: `Needs contained execution: ${baseCmd}` };
535
+ }
536
+
537
+ // ── Linters without --fix (read-only) ──
538
+ if (/^(ruff check|mypy|pyright|pylint|flake8|tsc --noEmit|eslint(?!\s+--fix))/.test(trimmed)) {
539
+ if (containsUnquotedExpansion(trimmed)) {
540
+ return { classification: 'contained', reason: 'Linter with expansion' };
541
+ }
542
+ return { classification: 'safe', reason: `Read-only linter: ${baseCmd}` };
543
+ }
544
+
545
+ // ── cd (safe but changes state) ──
546
+ if (/^cd(?:\s|$)/.test(trimmed)) {
547
+ if (containsUnquotedExpansion(trimmed)) {
548
+ return { classification: 'contained', reason: 'cd with expansion' };
549
+ }
550
+ return { classification: 'safe', reason: 'Change directory' };
551
+ }
552
+
553
+ // ── echo (safe if no expansion) ──
554
+ if (/^echo\s/.test(trimmed) && !containsUnquotedExpansion(trimmed)) {
555
+ return { classification: 'safe', reason: 'Echo without expansion' };
556
+ }
557
+
558
+ // ── Default: contained (unknown command, not explicitly blocked) ──
559
+ return { classification: 'contained', reason: `Unknown command, defaulting to contained: ${baseCmd}` };
560
+ }
561
+
562
+ /**
563
+ * Check if a command exit code represents an error (semantic-aware).
564
+ */
565
+ export function isExitCodeError(command, exitCode) {
566
+ const baseCmd = extractBaseCommand(command);
567
+ const semantic = EXIT_CODE_SEMANTICS[baseCmd];
568
+ if (semantic) return semantic(exitCode);
569
+ return exitCode !== 0;
570
+ }
571
+
572
+ // Re-export for use in tool-executor
573
+ export { splitCommand, extractBaseCommand, tokenize, containsUnquotedExpansion };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Command Injection Check — detect dangerous shell patterns.
3
+ *
4
+ * Scans commands for common injection vectors before allowing
5
+ * Bash tool execution.
6
+ */
7
+
8
+ const DANGEROUS_PATTERNS = [
9
+ { pattern: /;\s*rm\s+-rf\s+\//, label: 'rm -rf /' },
10
+ { pattern: /\|\s*sh\b/, label: 'pipe to sh' },
11
+ { pattern: /\|\s*bash\b/, label: 'pipe to bash' },
12
+ { pattern: /`[^`]+`/, label: 'backtick execution' },
13
+ { pattern: /\$\([^)]+\)/, label: 'command substitution' },
14
+ { pattern: />\s*\/etc\//, label: 'write to /etc' },
15
+ { pattern: />\s*\/usr\//, label: 'write to /usr' },
16
+ { pattern: /curl\s.*\|\s*(bash|sh)/, label: 'curl pipe to shell' },
17
+ { pattern: /wget\s.*\|\s*(bash|sh)/, label: 'wget pipe to shell' },
18
+ { pattern: /mkfs\./, label: 'filesystem format' },
19
+ { pattern: /dd\s+if=.*of=\/dev\//, label: 'dd to device' },
20
+ { pattern: /:\(\)\s*\{.*\|.*&\s*\}/, label: 'fork bomb' },
21
+ { pattern: /chmod\s+777\s+\//, label: 'chmod 777 root' },
22
+ { pattern: />\s*\/dev\/sda/, label: 'write to disk device' },
23
+ { pattern: /eval\s+"?\$/, label: 'eval variable' },
24
+ ];
25
+
26
+ /**
27
+ * Check a command string for injection patterns.
28
+ * @param {string} command - shell command to check
29
+ * @returns {{ safe: boolean, pattern?: string, label?: string }}
30
+ */
31
+ export function checkInjection(command) {
32
+ if (typeof command !== 'string') {
33
+ return { safe: false, label: 'non-string command' };
34
+ }
35
+
36
+ for (const { pattern, label } of DANGEROUS_PATTERNS) {
37
+ if (pattern.test(command)) {
38
+ return { safe: false, pattern: pattern.source, label };
39
+ }
40
+ }
41
+
42
+ return { safe: true };
43
+ }
44
+
45
+ /**
46
+ * Get the list of dangerous patterns (for display/testing).
47
+ * @returns {Array<{ pattern: RegExp, label: string }>}
48
+ */
49
+ export function getDangerousPatterns() {
50
+ return DANGEROUS_PATTERNS.map(({ pattern, label }) => ({ pattern, label }));
51
+ }
52
+
53
+ /**
54
+ * Check if a command uses any elevated privilege patterns.
55
+ * @param {string} command
56
+ * @returns {boolean}
57
+ */
58
+ export function usesElevation(command) {
59
+ return /\bsudo\b/.test(command) || /\bsu\s+-?\s/.test(command) || /\bdoas\b/.test(command);
60
+ }