@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.
- package/README.md +82 -0
- package/package.json +36 -4
- package/pulse/app/activity/page.tsx +190 -0
- package/pulse/app/api/activity/route.ts +138 -0
- package/pulse/app/api/costs/route.ts +88 -0
- package/pulse/app/api/export/route.ts +77 -0
- package/pulse/app/api/history/route.ts +11 -0
- package/pulse/app/api/import/route.ts +31 -0
- package/pulse/app/api/memory/route.ts +52 -0
- package/pulse/app/api/plans/route.ts +9 -0
- package/pulse/app/api/projects/[slug]/route.ts +96 -0
- package/pulse/app/api/projects/route.ts +121 -0
- package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
- package/pulse/app/api/sessions/[id]/route.ts +31 -0
- package/pulse/app/api/sessions/route.ts +112 -0
- package/pulse/app/api/settings/route.ts +14 -0
- package/pulse/app/api/stats/route.ts +143 -0
- package/pulse/app/api/todos/route.ts +9 -0
- package/pulse/app/api/tools/route.ts +160 -0
- package/pulse/app/costs/page.tsx +179 -0
- package/pulse/app/export/page.tsx +465 -0
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +263 -0
- package/pulse/app/help/page.tsx +142 -0
- package/pulse/app/history/page.tsx +157 -0
- package/pulse/app/layout.tsx +46 -0
- package/pulse/app/memory/page.tsx +365 -0
- package/pulse/app/overview-client.tsx +393 -0
- package/pulse/app/page.tsx +14 -0
- package/pulse/app/plans/page.tsx +308 -0
- package/pulse/app/projects/[slug]/page.tsx +390 -0
- package/pulse/app/projects/page.tsx +110 -0
- package/pulse/app/sessions/[id]/page.tsx +243 -0
- package/pulse/app/sessions/page.tsx +39 -0
- package/pulse/app/settings/page.tsx +188 -0
- package/pulse/app/todos/page.tsx +211 -0
- package/pulse/app/tools/page.tsx +249 -0
- package/pulse/cli.js +159 -0
- package/pulse/components/activity/day-of-week-chart.tsx +35 -0
- package/pulse/components/activity/streak-card.tsx +36 -0
- package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
- package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
- package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
- package/pulse/components/costs/model-token-table.tsx +60 -0
- package/pulse/components/global-search.tsx +193 -0
- package/pulse/components/keyboard-nav-provider.tsx +23 -0
- package/pulse/components/layout/bottom-nav.tsx +52 -0
- package/pulse/components/layout/client-layout.tsx +31 -0
- package/pulse/components/layout/sidebar-context.tsx +50 -0
- package/pulse/components/layout/sidebar.tsx +182 -0
- package/pulse/components/layout/top-bar.tsx +121 -0
- package/pulse/components/overview/activity-heatmap.tsx +107 -0
- package/pulse/components/overview/conversation-table.tsx +148 -0
- package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
- package/pulse/components/overview/peak-hours-chart.tsx +87 -0
- package/pulse/components/overview/project-activity-donut.tsx +96 -0
- package/pulse/components/overview/stat-card.tsx +102 -0
- package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
- package/pulse/components/projects/project-card.tsx +175 -0
- package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
- package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
- package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
- package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
- package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
- package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
- package/pulse/components/sessions/session-badges.tsx +49 -0
- package/pulse/components/sessions/session-table.tsx +299 -0
- package/pulse/components/theme-provider.tsx +44 -0
- package/pulse/components/tools/feature-adoption-table.tsx +58 -0
- package/pulse/components/tools/mcp-server-panel.tsx +45 -0
- package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
- package/pulse/components/tools/version-history-table.tsx +32 -0
- package/pulse/components/ui/alert.tsx +66 -0
- package/pulse/components/ui/badge.tsx +48 -0
- package/pulse/components/ui/breadcrumb.tsx +109 -0
- package/pulse/components/ui/button.tsx +64 -0
- package/pulse/components/ui/calendar.tsx +220 -0
- package/pulse/components/ui/card.tsx +92 -0
- package/pulse/components/ui/command.tsx +158 -0
- package/pulse/components/ui/dialog.tsx +158 -0
- package/pulse/components/ui/input.tsx +21 -0
- package/pulse/components/ui/popover.tsx +89 -0
- package/pulse/components/ui/progress.tsx +31 -0
- package/pulse/components/ui/select.tsx +190 -0
- package/pulse/components/ui/separator.tsx +28 -0
- package/pulse/components/ui/sheet.tsx +143 -0
- package/pulse/components/ui/skeleton.tsx +13 -0
- package/pulse/components/ui/table.tsx +116 -0
- package/pulse/components/ui/tabs.tsx +91 -0
- package/pulse/components/ui/tooltip.tsx +57 -0
- package/pulse/components/use-global-keyboard-nav.ts +79 -0
- package/pulse/components.json +23 -0
- package/pulse/eslint.config.mjs +18 -0
- package/pulse/lib/claude-reader.ts +594 -0
- package/pulse/lib/decode.ts +129 -0
- package/pulse/lib/pricing.ts +102 -0
- package/pulse/lib/replay-parser.ts +165 -0
- package/pulse/lib/tool-categories.ts +127 -0
- package/pulse/lib/utils.ts +6 -0
- package/pulse/next-env.d.ts +6 -0
- package/pulse/next.config.ts +16 -0
- package/pulse/package.json +45 -0
- package/pulse/postcss.config.mjs +7 -0
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +1 -0
- package/pulse/public/globe.svg +1 -0
- package/pulse/public/next.svg +1 -0
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +1 -0
- package/pulse/public/window.svg +1 -0
- package/pulse/tsconfig.json +34 -0
- package/pulse/types/claude.ts +294 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/auth/tarang-auth.mjs +277 -0
- package/src/config/cli-args.mjs +173 -0
- package/src/config/env.mjs +263 -0
- package/src/config/settings.mjs +132 -0
- package/src/context/ast-parser.mjs +298 -0
- package/src/context/bm25.mjs +85 -0
- package/src/context/retriever.mjs +270 -0
- package/src/context/skeleton.mjs +134 -0
- package/src/core/agent-loop.mjs +480 -0
- package/src/core/approval.mjs +273 -0
- package/src/core/backend-url.mjs +57 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/callback-client.mjs +149 -0
- package/src/core/checkpoints.mjs +142 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/headless.mjs +168 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +351 -0
- package/src/core/local-agent.mjs +429 -0
- package/src/core/local-store.mjs +325 -0
- package/src/core/mode-selector.mjs +51 -0
- package/src/core/output-filter.mjs +177 -0
- package/src/core/paths.mjs +98 -0
- package/src/core/pricing.mjs +314 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/safety.mjs +200 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session-manager.mjs +317 -0
- package/src/core/session.mjs +143 -0
- package/src/core/settings-sync.mjs +85 -0
- package/src/core/stagnation.mjs +57 -0
- package/src/core/stream-client.mjs +367 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/core/tool-executor.mjs +725 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +370 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +573 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/terminal/agents.mjs +177 -0
- package/src/terminal/analytics.mjs +292 -0
- package/src/terminal/ansi.mjs +421 -0
- package/src/terminal/main.mjs +150 -0
- package/src/terminal/repl.mjs +1484 -0
- package/src/terminal/tool-display.mjs +58 -0
- package/src/tools/agent.mjs +137 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +129 -0
- package/src/tools/lint.mjs +71 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +132 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/test-runner.mjs +49 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/banner.mjs +237 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/formatter.mjs +379 -0
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/slash-commands.mjs +258 -0
- 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
|
+
}
|