permission-pi 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +277 -0
- package/package.json +29 -0
- package/permission-core.ts +1194 -0
- package/permission.ts +609 -0
- package/tests/permission.test.ts +1438 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core permission logic - command classification and settings
|
|
3
|
+
*
|
|
4
|
+
* This module contains pure functions for:
|
|
5
|
+
* - Parsing shell commands
|
|
6
|
+
* - Classifying commands by required permission level
|
|
7
|
+
* - Detecting dangerous commands
|
|
8
|
+
* - Managing settings persistence
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { parse } from "shell-quote";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export type PermissionLevel = "minimal" | "low" | "medium" | "high" | "bypassed";
|
|
20
|
+
|
|
21
|
+
export type PermissionMode = "ask" | "block";
|
|
22
|
+
|
|
23
|
+
export const LEVELS: PermissionLevel[] = ["minimal", "low", "medium", "high", "bypassed"];
|
|
24
|
+
export const PERMISSION_MODES: PermissionMode[] = ["ask", "block"];
|
|
25
|
+
|
|
26
|
+
export const LEVEL_INDEX: Record<PermissionLevel, number> = {
|
|
27
|
+
minimal: 0,
|
|
28
|
+
low: 1,
|
|
29
|
+
medium: 2,
|
|
30
|
+
high: 3,
|
|
31
|
+
bypassed: 4,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const LEVEL_INFO: Record<PermissionLevel, { label: string; desc: string }> = {
|
|
35
|
+
minimal: { label: "Minimal", desc: "Read-only" },
|
|
36
|
+
low: { label: "Low", desc: "File ops only" },
|
|
37
|
+
medium: { label: "Medium", desc: "Dev operations" },
|
|
38
|
+
high: { label: "High", desc: "Full operations" },
|
|
39
|
+
bypassed: { label: "Bypassed", desc: "All checks disabled" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const PERMISSION_MODE_INFO: Record<PermissionMode, { label: string; desc: string }> = {
|
|
43
|
+
ask: { label: "Ask", desc: "Prompt when permission is required" },
|
|
44
|
+
block: { label: "Block", desc: "Block instead of prompting" },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const LEVEL_ALLOWED_DESC: Record<PermissionLevel, string> = {
|
|
48
|
+
minimal: "read-only (cat, ls, grep, git status/diff/log, npm list, version checks)",
|
|
49
|
+
low: "read-only + file write/edit",
|
|
50
|
+
medium: "dev ops (install packages, build, test, git commit/pull, file operations)",
|
|
51
|
+
high: "full operations except dangerous commands",
|
|
52
|
+
bypassed: "all operations",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface Classification {
|
|
56
|
+
level: PermissionLevel;
|
|
57
|
+
dangerous: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// CONFIGURATION TYPES
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
export interface PermissionConfig {
|
|
65
|
+
/** Override patterns to force specific permission levels */
|
|
66
|
+
overrides?: {
|
|
67
|
+
minimal?: string[];
|
|
68
|
+
low?: string[];
|
|
69
|
+
medium?: string[];
|
|
70
|
+
high?: string[];
|
|
71
|
+
dangerous?: string[];
|
|
72
|
+
};
|
|
73
|
+
/** Prefix mappings to normalize commands before classification */
|
|
74
|
+
prefixMappings?: Array<{
|
|
75
|
+
from: string;
|
|
76
|
+
to: string;
|
|
77
|
+
}>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// CONFIGURATION CACHING
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
let configCache: PermissionConfig | null = null;
|
|
85
|
+
let configCacheTime = 0;
|
|
86
|
+
/** Cache TTL in milliseconds - balance between responsiveness and performance */
|
|
87
|
+
const CONFIG_CACHE_TTL = 5000; // 5 seconds
|
|
88
|
+
|
|
89
|
+
let regexCache: Map<string, RegExp> = new Map();
|
|
90
|
+
/** Maximum cached regex patterns to prevent memory exhaustion */
|
|
91
|
+
const MAX_REGEX_CACHE_SIZE = 500;
|
|
92
|
+
|
|
93
|
+
function getCachedConfig(): PermissionConfig {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (!configCache || now - configCacheTime > CONFIG_CACHE_TTL) {
|
|
96
|
+
configCache = loadPermissionConfig();
|
|
97
|
+
configCacheTime = now;
|
|
98
|
+
}
|
|
99
|
+
return configCache;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getCachedRegex(pattern: string): RegExp {
|
|
103
|
+
let regex = regexCache.get(pattern);
|
|
104
|
+
if (!regex) {
|
|
105
|
+
// Evict oldest entries if cache is full (simple FIFO eviction)
|
|
106
|
+
if (regexCache.size >= MAX_REGEX_CACHE_SIZE) {
|
|
107
|
+
const firstKey = regexCache.keys().next().value;
|
|
108
|
+
if (firstKey) regexCache.delete(firstKey);
|
|
109
|
+
}
|
|
110
|
+
regex = globToRegex(pattern);
|
|
111
|
+
regexCache.set(pattern, regex);
|
|
112
|
+
}
|
|
113
|
+
return regex;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function invalidateConfigCache(): void {
|
|
117
|
+
configCache = null;
|
|
118
|
+
regexCache.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validate and sanitize permission config
|
|
123
|
+
* Returns a safe config object with invalid entries removed
|
|
124
|
+
*/
|
|
125
|
+
function validateConfig(config: unknown): PermissionConfig {
|
|
126
|
+
if (!config || typeof config !== 'object') {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result: PermissionConfig = {};
|
|
131
|
+
const raw = config as Record<string, unknown>;
|
|
132
|
+
|
|
133
|
+
// Validate overrides
|
|
134
|
+
if (raw.overrides && typeof raw.overrides === 'object') {
|
|
135
|
+
const overrides = raw.overrides as Record<string, unknown>;
|
|
136
|
+
result.overrides = {};
|
|
137
|
+
|
|
138
|
+
const levels = ['minimal', 'low', 'medium', 'high', 'dangerous'] as const;
|
|
139
|
+
for (const level of levels) {
|
|
140
|
+
const patterns = overrides[level];
|
|
141
|
+
if (Array.isArray(patterns)) {
|
|
142
|
+
// Filter to only valid string patterns, limit count
|
|
143
|
+
const validPatterns = patterns
|
|
144
|
+
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
145
|
+
.slice(0, 100); // Max 100 patterns per level
|
|
146
|
+
if (validPatterns.length > 0) {
|
|
147
|
+
result.overrides[level] = validPatterns;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate prefix mappings
|
|
154
|
+
if (Array.isArray(raw.prefixMappings)) {
|
|
155
|
+
const validMappings = raw.prefixMappings
|
|
156
|
+
.filter((m): m is { from: string; to: string } =>
|
|
157
|
+
m && typeof m === 'object' &&
|
|
158
|
+
typeof (m as any).from === 'string' && (m as any).from.length > 0 &&
|
|
159
|
+
typeof (m as any).to === 'string'
|
|
160
|
+
)
|
|
161
|
+
.slice(0, 50); // Max 50 prefix mappings
|
|
162
|
+
if (validMappings.length > 0) {
|
|
163
|
+
result.prefixMappings = validMappings;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// PATTERN MATCHING
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Convert a glob-like pattern to a RegExp
|
|
176
|
+
* Supports: * (any chars), ? (single char)
|
|
177
|
+
* Patterns are matched against the full command string
|
|
178
|
+
*/
|
|
179
|
+
function globToRegex(pattern: string): RegExp {
|
|
180
|
+
try {
|
|
181
|
+
// Limit pattern complexity to prevent ReDoS
|
|
182
|
+
// Reject patterns with too many consecutive * (creates .*.*.*... patterns)
|
|
183
|
+
if (/\*{5,}/.test(pattern)) {
|
|
184
|
+
// More than 4 consecutive * - reject to prevent exponential backtracking
|
|
185
|
+
return /(?!)/;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Escape regex special chars first (except * and ? which we handle specially)
|
|
189
|
+
// Note: - is not special outside character classes, so we don't need to escape it
|
|
190
|
+
let regex = pattern
|
|
191
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
192
|
+
.replace(/\*/g, '.*') // * -> match any characters
|
|
193
|
+
.replace(/\?/g, '.'); // ? -> match single character
|
|
194
|
+
|
|
195
|
+
return new RegExp(`^${regex}$`, 'i');
|
|
196
|
+
} catch {
|
|
197
|
+
// Return a pattern that never matches on invalid input
|
|
198
|
+
return /(?!)/;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if a command matches any pattern in the list
|
|
204
|
+
*/
|
|
205
|
+
function matchesAnyPattern(command: string, patterns: string[] | undefined | null): boolean {
|
|
206
|
+
if (!patterns || !Array.isArray(patterns) || patterns.length === 0) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return patterns.some(pattern =>
|
|
210
|
+
typeof pattern === 'string' && getCachedRegex(pattern).test(command)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Apply prefix mappings to normalize command before classification
|
|
216
|
+
* e.g., "fvm flutter build" → "flutter build"
|
|
217
|
+
*/
|
|
218
|
+
function applyPrefixMappings(
|
|
219
|
+
command: string,
|
|
220
|
+
mappings: PermissionConfig['prefixMappings']
|
|
221
|
+
): string {
|
|
222
|
+
if (!mappings || !Array.isArray(mappings) || mappings.length === 0) return command;
|
|
223
|
+
|
|
224
|
+
const trimmed = command.trim();
|
|
225
|
+
const trimmedLower = trimmed.toLowerCase();
|
|
226
|
+
|
|
227
|
+
for (const mapping of mappings) {
|
|
228
|
+
// Validate mapping structure
|
|
229
|
+
if (!mapping || typeof mapping.from !== 'string' || typeof mapping.to !== 'string') {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { from, to } = mapping;
|
|
234
|
+
const fromLower = from.toLowerCase();
|
|
235
|
+
|
|
236
|
+
if (trimmedLower.startsWith(fromLower)) {
|
|
237
|
+
// Check for word boundary (whitespace or end of string after prefix)
|
|
238
|
+
const afterPrefix = trimmed.substring(fromLower.length);
|
|
239
|
+
// Use regex to check for whitespace boundary (handles tabs, multiple spaces)
|
|
240
|
+
if (afterPrefix === '' || /^\s/.test(afterPrefix)) {
|
|
241
|
+
// Replace prefix with mapped value, preserve rest with trimmed leading space
|
|
242
|
+
const remainder = afterPrefix.replace(/^\s+/, '');
|
|
243
|
+
if (to === '') {
|
|
244
|
+
return remainder;
|
|
245
|
+
}
|
|
246
|
+
return remainder ? `${to} ${remainder}` : to;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return command;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if command matches any configured override
|
|
256
|
+
* Returns the override classification or null if no match
|
|
257
|
+
*/
|
|
258
|
+
function checkOverrides(
|
|
259
|
+
command: string,
|
|
260
|
+
overrides: PermissionConfig['overrides']
|
|
261
|
+
): Classification | null {
|
|
262
|
+
if (!overrides) return null;
|
|
263
|
+
|
|
264
|
+
const trimmed = command.trim();
|
|
265
|
+
|
|
266
|
+
// Check dangerous first (highest priority)
|
|
267
|
+
if (overrides.dangerous && matchesAnyPattern(trimmed, overrides.dangerous)) {
|
|
268
|
+
return { level: 'high', dangerous: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check levels in order of specificity (high to low)
|
|
272
|
+
if (overrides.high && matchesAnyPattern(trimmed, overrides.high)) {
|
|
273
|
+
return { level: 'high', dangerous: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (overrides.medium && matchesAnyPattern(trimmed, overrides.medium)) {
|
|
277
|
+
return { level: 'medium', dangerous: false };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (overrides.low && matchesAnyPattern(trimmed, overrides.low)) {
|
|
281
|
+
return { level: 'low', dangerous: false };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (overrides.minimal && matchesAnyPattern(trimmed, overrides.minimal)) {
|
|
285
|
+
return { level: 'minimal', dangerous: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return null; // No override matched
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// SETTINGS PERSISTENCE
|
|
293
|
+
// ============================================================================
|
|
294
|
+
|
|
295
|
+
function getSettingsPath(): string {
|
|
296
|
+
return path.join(process.env.HOME || "", ".pi", "agent", "settings.json");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function loadSettings(): Record<string, unknown> {
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(fs.readFileSync(getSettingsPath(), "utf-8"));
|
|
302
|
+
} catch {
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function saveSettings(settings: Record<string, unknown>): void {
|
|
308
|
+
const settingsPath = getSettingsPath();
|
|
309
|
+
const dir = path.dirname(settingsPath);
|
|
310
|
+
const tempPath = `${settingsPath}.tmp`;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
if (!fs.existsSync(dir)) {
|
|
314
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
315
|
+
}
|
|
316
|
+
// Atomic write: write to temp file first, then rename
|
|
317
|
+
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + "\n");
|
|
318
|
+
fs.renameSync(tempPath, settingsPath); // Atomic on POSIX systems
|
|
319
|
+
} catch (e) {
|
|
320
|
+
// Clean up temp file on error
|
|
321
|
+
try {
|
|
322
|
+
if (fs.existsSync(tempPath)) {
|
|
323
|
+
fs.unlinkSync(tempPath);
|
|
324
|
+
}
|
|
325
|
+
} catch {}
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function loadGlobalPermission(): PermissionLevel | null {
|
|
331
|
+
const settings = loadSettings();
|
|
332
|
+
const level = (settings.permissionLevel as string)?.toLowerCase();
|
|
333
|
+
if (level && LEVELS.includes(level as PermissionLevel)) {
|
|
334
|
+
return level as PermissionLevel;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function saveGlobalPermission(level: PermissionLevel): void {
|
|
340
|
+
const settings = loadSettings();
|
|
341
|
+
settings.permissionLevel = level;
|
|
342
|
+
saveSettings(settings);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function loadGlobalPermissionMode(): PermissionMode | null {
|
|
346
|
+
const settings = loadSettings();
|
|
347
|
+
const mode = (settings.permissionMode as string)?.toLowerCase();
|
|
348
|
+
if (mode && PERMISSION_MODES.includes(mode as PermissionMode)) {
|
|
349
|
+
return mode as PermissionMode;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function saveGlobalPermissionMode(mode: PermissionMode): void {
|
|
355
|
+
const settings = loadSettings();
|
|
356
|
+
settings.permissionMode = mode;
|
|
357
|
+
saveSettings(settings);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function loadPermissionConfig(): PermissionConfig {
|
|
361
|
+
const settings = loadSettings();
|
|
362
|
+
return validateConfig(settings.permissionConfig);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function savePermissionConfig(config: PermissionConfig): void {
|
|
366
|
+
const settings = loadSettings();
|
|
367
|
+
settings.permissionConfig = config;
|
|
368
|
+
saveSettings(settings);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// COMMAND PARSING
|
|
373
|
+
// ============================================================================
|
|
374
|
+
|
|
375
|
+
interface ParsedCommand {
|
|
376
|
+
segments: string[][]; // Commands split by operators
|
|
377
|
+
operators: string[]; // |, &&, ||, ;
|
|
378
|
+
raw: string;
|
|
379
|
+
hasShellTricks?: boolean;
|
|
380
|
+
/** Output redirections to non-special files (>, >>) */
|
|
381
|
+
writesFiles?: boolean;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Shell execution commands that can run arbitrary code
|
|
385
|
+
const SHELL_EXECUTION_COMMANDS = new Set([
|
|
386
|
+
"eval", "exec", "source", ".", // shell builtins
|
|
387
|
+
"env", // can execute commands: env rm -rf /
|
|
388
|
+
"command", // bypasses aliases, can execute arbitrary commands
|
|
389
|
+
"builtin", // uses shell builtins directly
|
|
390
|
+
// Wrapper commands that can execute arbitrary commands
|
|
391
|
+
"time", "nice", "nohup", "timeout", "watch", "strace",
|
|
392
|
+
// Note: xargs is handled in CONDITIONAL_WRITE_COMMANDS with smart logic
|
|
393
|
+
]);
|
|
394
|
+
|
|
395
|
+
// Patterns that indicate command substitution or shell tricks in raw command
|
|
396
|
+
// Only patterns that can actually execute arbitrary code
|
|
397
|
+
const SHELL_TRICK_PATTERNS = [
|
|
398
|
+
/\$\((?!\()[^)]+\)/, // $(command) - command substitution (exclude $(( for arithmetic)
|
|
399
|
+
/`[^`]+`/, // `command` - backtick substitution
|
|
400
|
+
/<\([^)]+\)/, // <(command) - process substitution (input)
|
|
401
|
+
/>\([^)]+\)/, // >(command) - process substitution (output)
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
// Check if ${...} contains nested command substitution
|
|
405
|
+
// Simple ${VAR} is safe, but ${VAR:-$(cmd)} or ${VAR:-`cmd`} is dangerous
|
|
406
|
+
function hasDangerousExpansion(command: string): boolean {
|
|
407
|
+
const braceExpansions = command.match(/\$\{[^}]+\}/g) || [];
|
|
408
|
+
for (const expansion of braceExpansions) {
|
|
409
|
+
// Check for nested $() or backticks inside ${...}
|
|
410
|
+
if (/\$\(|\`/.test(expansion)) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function detectShellTricks(command: string): boolean {
|
|
418
|
+
// Check basic patterns first
|
|
419
|
+
if (SHELL_TRICK_PATTERNS.some(pattern => pattern.test(command))) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
// Check for dangerous ${...} expansions with nested command substitution
|
|
423
|
+
if (hasDangerousExpansion(command)) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Check if a command contains arithmetic expansion $((..))
|
|
431
|
+
* Used to avoid false positives from shell-quote parsing
|
|
432
|
+
*/
|
|
433
|
+
function hasArithmeticExpansion(command: string): boolean {
|
|
434
|
+
return /\$\(\(/.test(command);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Output redirection operators that write to files
|
|
438
|
+
const OUTPUT_REDIRECTION_OPS = new Set([">", ">>", ">|", "&>", "&>>"]);
|
|
439
|
+
|
|
440
|
+
// Safe redirection targets (not actual file writes)
|
|
441
|
+
const SAFE_REDIRECTION_TARGETS = new Set([
|
|
442
|
+
"/dev/null", "/dev/stdout", "/dev/stderr",
|
|
443
|
+
"/dev/fd/1", "/dev/fd/2",
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
function parseCommand(command: string): ParsedCommand {
|
|
447
|
+
const hasShellTricks = detectShellTricks(command);
|
|
448
|
+
|
|
449
|
+
// shell-quote can throw on complex patterns it doesn't understand
|
|
450
|
+
// In that case, treat the command as having shell tricks (require high permission)
|
|
451
|
+
let tokens: ReturnType<typeof parse>;
|
|
452
|
+
try {
|
|
453
|
+
tokens = parse(command);
|
|
454
|
+
} catch {
|
|
455
|
+
// Parse failed - treat as dangerous
|
|
456
|
+
return {
|
|
457
|
+
segments: [],
|
|
458
|
+
operators: [],
|
|
459
|
+
raw: command,
|
|
460
|
+
hasShellTricks: true
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const segments: string[][] = [];
|
|
465
|
+
const operators: string[] = [];
|
|
466
|
+
let currentSegment: string[] = [];
|
|
467
|
+
let foundCommandSubstitution = false;
|
|
468
|
+
let writesFiles = false;
|
|
469
|
+
|
|
470
|
+
// Redirection operators - these don't start new command segments
|
|
471
|
+
const REDIRECTION_OPS = new Set([">", "<", ">>", ">&", "<&", ">|", "<>", "&>", "&>>"]);
|
|
472
|
+
let pendingOutputRedirect = false;
|
|
473
|
+
|
|
474
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
475
|
+
const token = tokens[i];
|
|
476
|
+
|
|
477
|
+
if (pendingOutputRedirect) {
|
|
478
|
+
// This token is a redirection target
|
|
479
|
+
pendingOutputRedirect = false;
|
|
480
|
+
if (typeof token === "string") {
|
|
481
|
+
// Check if this is writing to a real file (not /dev/null etc.)
|
|
482
|
+
if (!SAFE_REDIRECTION_TARGETS.has(token) && !token.startsWith("/dev/fd/")) {
|
|
483
|
+
writesFiles = true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (typeof token === "string") {
|
|
490
|
+
currentSegment.push(token);
|
|
491
|
+
} else if (token && typeof token === "object") {
|
|
492
|
+
if ("op" in token) {
|
|
493
|
+
const op = token.op as string;
|
|
494
|
+
if (REDIRECTION_OPS.has(op)) {
|
|
495
|
+
// Check if this is an output redirection
|
|
496
|
+
if (OUTPUT_REDIRECTION_OPS.has(op)) {
|
|
497
|
+
pendingOutputRedirect = true;
|
|
498
|
+
} else {
|
|
499
|
+
// Input redirection or fd duplication - skip next token
|
|
500
|
+
// For >&, <& we need to check if it's fd duplication (2>&1) or file redirect
|
|
501
|
+
if (op === ">&" || op === "<&") {
|
|
502
|
+
const nextToken = tokens[i + 1];
|
|
503
|
+
if (typeof nextToken === "string" && /^\d+$/.test(nextToken)) {
|
|
504
|
+
// fd duplication like 2>&1, skip it
|
|
505
|
+
i++;
|
|
506
|
+
} else {
|
|
507
|
+
// File redirect like >&file
|
|
508
|
+
pendingOutputRedirect = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
// Only treat actual command separators as segment boundaries
|
|
514
|
+
// ( and ) are grouping/subshell/arithmetic operators, not separators
|
|
515
|
+
const COMMAND_SEPARATORS = new Set(["|", "&&", "||", ";", "&"]);
|
|
516
|
+
if (COMMAND_SEPARATORS.has(op)) {
|
|
517
|
+
if (currentSegment.length > 0) {
|
|
518
|
+
segments.push(currentSegment);
|
|
519
|
+
currentSegment = [];
|
|
520
|
+
}
|
|
521
|
+
operators.push(op);
|
|
522
|
+
}
|
|
523
|
+
// Ignore ( and ) - they don't create new command segments
|
|
524
|
+
}
|
|
525
|
+
} else if ("comment" in token) {
|
|
526
|
+
// Comment - ignore
|
|
527
|
+
} else {
|
|
528
|
+
// shell-quote returns special objects for:
|
|
529
|
+
// - { op: 'glob', pattern: '*.js' } - globs
|
|
530
|
+
// - { op: string } - operators
|
|
531
|
+
// Any other object type indicates shell parsing complexity
|
|
532
|
+
// that we should treat as potentially dangerous
|
|
533
|
+
foundCommandSubstitution = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (currentSegment.length > 0) {
|
|
539
|
+
segments.push(currentSegment);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
segments,
|
|
544
|
+
operators,
|
|
545
|
+
raw: command,
|
|
546
|
+
hasShellTricks: hasShellTricks || foundCommandSubstitution,
|
|
547
|
+
writesFiles
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function getCommandName(tokens: string[]): string {
|
|
552
|
+
if (tokens.length === 0) return "";
|
|
553
|
+
|
|
554
|
+
let cmd = tokens[0];
|
|
555
|
+
|
|
556
|
+
// Strip path prefix
|
|
557
|
+
if (cmd.includes("/")) {
|
|
558
|
+
cmd = cmd.split("/").pop() || cmd;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Strip leading backslash (alias bypass)
|
|
562
|
+
if (cmd.startsWith("\\")) {
|
|
563
|
+
cmd = cmd.slice(1);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return cmd.toLowerCase();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ============================================================================
|
|
570
|
+
// DANGEROUS COMMAND DETECTION
|
|
571
|
+
// ============================================================================
|
|
572
|
+
|
|
573
|
+
function isDangerousCommand(tokens: string[]): boolean {
|
|
574
|
+
if (tokens.length === 0) return false;
|
|
575
|
+
|
|
576
|
+
const cmd = getCommandName(tokens);
|
|
577
|
+
const args = tokens.slice(1);
|
|
578
|
+
const argsStr = args.join(" ");
|
|
579
|
+
|
|
580
|
+
// sudo - always dangerous
|
|
581
|
+
if (cmd === "sudo") return true;
|
|
582
|
+
|
|
583
|
+
// rm with recursive + force
|
|
584
|
+
if (cmd === "rm") {
|
|
585
|
+
let hasRecursive = false;
|
|
586
|
+
let hasForce = false;
|
|
587
|
+
|
|
588
|
+
for (const arg of args) {
|
|
589
|
+
if (arg === "--recursive") hasRecursive = true;
|
|
590
|
+
if (arg === "--force") hasForce = true;
|
|
591
|
+
if (arg.startsWith("-") && !arg.startsWith("--")) {
|
|
592
|
+
if (arg.includes("r") || arg.includes("R")) hasRecursive = true;
|
|
593
|
+
if (arg.includes("f")) hasForce = true;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (hasRecursive && hasForce) return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// chmod 777 or a+rwx
|
|
601
|
+
if (cmd === "chmod") {
|
|
602
|
+
if (argsStr.includes("777") || argsStr.includes("a+rwx")) return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// dd to device
|
|
606
|
+
if (cmd === "dd") {
|
|
607
|
+
if (argsStr.match(/of=\/dev\//)) return true;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Dangerous system commands
|
|
611
|
+
if (["fdisk", "parted", "format"].includes(cmd)) return true;
|
|
612
|
+
if (cmd.startsWith("mkfs")) return true; // mkfs, mkfs.ext4, mkfs.xfs, etc.
|
|
613
|
+
|
|
614
|
+
// Shutdown/reboot
|
|
615
|
+
if (["shutdown", "reboot", "halt", "poweroff", "init"].includes(cmd)) return true;
|
|
616
|
+
|
|
617
|
+
// Fork bomb pattern
|
|
618
|
+
if (tokens.join("").includes(":(){ :|:& };:")) return true;
|
|
619
|
+
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ============================================================================
|
|
624
|
+
// LEVEL CLASSIFICATION
|
|
625
|
+
// ============================================================================
|
|
626
|
+
|
|
627
|
+
// Common redirection targets (treated as read-only)
|
|
628
|
+
const REDIRECTION_TARGETS = new Set([
|
|
629
|
+
"/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr",
|
|
630
|
+
"/dev/zero", "/dev/full", "/dev/random", "/dev/urandom",
|
|
631
|
+
"/dev/fd", "/dev/tty", "/dev/ptmx",
|
|
632
|
+
]);
|
|
633
|
+
|
|
634
|
+
// File descriptor numbers used in redirections (e.g., 2>&1)
|
|
635
|
+
const FD_NUMBERS = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);
|
|
636
|
+
|
|
637
|
+
// MINIMAL level - read-only commands
|
|
638
|
+
const MINIMAL_COMMANDS = new Set([
|
|
639
|
+
// File reading
|
|
640
|
+
"cat", "less", "more", "head", "tail", "bat", "tac",
|
|
641
|
+
// Directory listing/navigation
|
|
642
|
+
"ls", "tree", "pwd", "dir", "vdir", "cd", "pushd", "popd", "dirs",
|
|
643
|
+
// Search (note: find handled specially due to -exec/-delete)
|
|
644
|
+
"grep", "egrep", "fgrep", "rg", "ag", "ack", "fd", "locate", "which", "whereis",
|
|
645
|
+
// Info
|
|
646
|
+
"echo", "printf", "whoami", "id", "date", "cal", "uname", "hostname", "uptime",
|
|
647
|
+
"type", "file", "stat", "wc", "du", "df", "free",
|
|
648
|
+
"ps", "top", "htop", "pgrep", "sleep",
|
|
649
|
+
// Man/help
|
|
650
|
+
"man", "help", "info",
|
|
651
|
+
// Pipeline utilities (note: xargs, tee handled specially - they can write/execute)
|
|
652
|
+
"sort", "uniq", "cut", "awk", "sed", "tr", "column", "paste", "join",
|
|
653
|
+
"comm", "diff", "cmp", "patch",
|
|
654
|
+
// Shell test commands (read-only conditionals)
|
|
655
|
+
"test", "[", "[[", "true", "false",
|
|
656
|
+
]);
|
|
657
|
+
|
|
658
|
+
// Commands that can write files based on arguments
|
|
659
|
+
// find: -exec, -execdir, -ok, -okdir, -delete can modify filesystem
|
|
660
|
+
// xargs: executes commands with input as arguments (but safe if running read-only commands)
|
|
661
|
+
// tee: writes to files (but read-only when used with /dev/null or --)
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Extract the command that xargs will execute.
|
|
665
|
+
* Parses xargs options to find the first non-option argument.
|
|
666
|
+
* Returns null if no command specified (xargs defaults to /bin/echo).
|
|
667
|
+
*/
|
|
668
|
+
function extractXargsCommand(tokens: string[]): string | null {
|
|
669
|
+
const args = tokens.slice(1); // Skip 'xargs' itself
|
|
670
|
+
|
|
671
|
+
// xargs options that consume the next argument
|
|
672
|
+
const OPTIONS_WITH_ARG = new Set(["-I", "-d", "-E", "-L", "-n", "-P", "-s", "-a"]);
|
|
673
|
+
|
|
674
|
+
let i = 0;
|
|
675
|
+
while (i < args.length) {
|
|
676
|
+
const arg = args[i];
|
|
677
|
+
|
|
678
|
+
// End of options marker
|
|
679
|
+
if (arg === "--") {
|
|
680
|
+
i++;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Not an option - this is the command
|
|
685
|
+
if (!arg.startsWith("-")) {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Long options (--null, --max-args=5, etc.)
|
|
690
|
+
if (arg.startsWith("--")) {
|
|
691
|
+
// Long options either are flags or use = for values, so just skip
|
|
692
|
+
i++;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Short option that takes a required argument
|
|
697
|
+
// Could be: -I {} (separate) or -I{} (attached)
|
|
698
|
+
const optLetter = arg.substring(0, 2); // e.g., "-I"
|
|
699
|
+
if (OPTIONS_WITH_ARG.has(optLetter)) {
|
|
700
|
+
if (arg.length > 2) {
|
|
701
|
+
// Argument attached: -I{} or -n10
|
|
702
|
+
i++;
|
|
703
|
+
} else {
|
|
704
|
+
// Argument is next token: -I {}
|
|
705
|
+
i += 2;
|
|
706
|
+
}
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// -i and -e can have optional attached argument (deprecated forms)
|
|
711
|
+
// -i[replstr], -e[eof-str]
|
|
712
|
+
if (arg.startsWith("-i") || arg.startsWith("-e")) {
|
|
713
|
+
i++;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Other short options are flags (can be combined): -0, -t, -p, -r, -x
|
|
718
|
+
// e.g., -0tr means -0 -t -r
|
|
719
|
+
i++;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Return the command if found
|
|
723
|
+
if (i < args.length) {
|
|
724
|
+
const cmd = args[i];
|
|
725
|
+
// Strip path prefix (e.g., /usr/bin/cat -> cat)
|
|
726
|
+
if (cmd.includes("/")) {
|
|
727
|
+
return cmd.split("/").pop()?.toLowerCase() || null;
|
|
728
|
+
}
|
|
729
|
+
return cmd.toLowerCase();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// No command found - xargs defaults to /bin/echo (safe)
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const CONDITIONAL_WRITE_COMMANDS: Record<string, (tokens: string[]) => boolean> = {
|
|
737
|
+
find: (tokens) => {
|
|
738
|
+
const dangerousFlags = ["-exec", "-execdir", "-ok", "-okdir", "-delete"];
|
|
739
|
+
return tokens.some(t => dangerousFlags.includes(t.toLowerCase()));
|
|
740
|
+
},
|
|
741
|
+
xargs: (tokens) => {
|
|
742
|
+
// xargs executes commands with input as arguments
|
|
743
|
+
// Safe if running a read-only command from MINIMAL_COMMANDS
|
|
744
|
+
const xargsCmd = extractXargsCommand(tokens);
|
|
745
|
+
|
|
746
|
+
// No command = defaults to /bin/echo (safe, just prints)
|
|
747
|
+
if (xargsCmd === null) return false;
|
|
748
|
+
|
|
749
|
+
// Check if the command xargs will run is read-only
|
|
750
|
+
if (MINIMAL_COMMANDS.has(xargsCmd)) return false;
|
|
751
|
+
|
|
752
|
+
// Unknown or non-minimal command - not safe
|
|
753
|
+
return true;
|
|
754
|
+
},
|
|
755
|
+
tee: (tokens) => {
|
|
756
|
+
// tee writes to files unless only used with /dev/null or --
|
|
757
|
+
const args = tokens.slice(1).filter(t => !t.startsWith("-"));
|
|
758
|
+
if (args.length === 0) return false; // tee with no file args writes to stdout only
|
|
759
|
+
// Check if all file args are /dev/null
|
|
760
|
+
return !args.every(a => a === "/dev/null");
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const MINIMAL_GIT_SUBCOMMANDS = new Set([
|
|
765
|
+
"status", "log", "diff", "show", "branch", "remote", "tag",
|
|
766
|
+
"ls-files", "ls-tree", "cat-file", "rev-parse", "describe",
|
|
767
|
+
"shortlog", "blame", "annotate", "whatchanged", "reflog",
|
|
768
|
+
"fetch", // read-only: just downloads refs, doesn't change working tree
|
|
769
|
+
]);
|
|
770
|
+
|
|
771
|
+
const MINIMAL_PACKAGE_SUBCOMMANDS: Record<string, Set<string>> = {
|
|
772
|
+
npm: new Set(["list", "ls", "info", "view", "outdated", "audit", "explain", "why", "search"]),
|
|
773
|
+
yarn: new Set(["list", "info", "why", "outdated", "audit"]),
|
|
774
|
+
pnpm: new Set(["list", "ls", "outdated", "audit", "why"]),
|
|
775
|
+
bun: new Set(["pm", "ls"]),
|
|
776
|
+
pip: new Set(["list", "show", "freeze", "check"]),
|
|
777
|
+
pip3: new Set(["list", "show", "freeze", "check"]),
|
|
778
|
+
cargo: new Set(["tree", "metadata", "search", "info"]),
|
|
779
|
+
go: new Set(["list", "version", "env"]),
|
|
780
|
+
gem: new Set(["list", "info", "search", "query"]),
|
|
781
|
+
composer: new Set(["show", "info", "search", "outdated", "audit"]),
|
|
782
|
+
dotnet: new Set(["list", "nuget"]),
|
|
783
|
+
flutter: new Set(["doctor", "devices", "config"]),
|
|
784
|
+
dart: new Set(["info"]),
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
function isMinimalLevel(tokens: string[]): boolean {
|
|
788
|
+
if (tokens.length === 0) return true;
|
|
789
|
+
|
|
790
|
+
const cmd = getCommandName(tokens);
|
|
791
|
+
const fullCmd = tokens[0]; // Keep full path for checking redirection targets
|
|
792
|
+
const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : "";
|
|
793
|
+
|
|
794
|
+
// Check if this is a file descriptor number from redirection parsing (e.g., "1" from 2>&1)
|
|
795
|
+
if (tokens.length === 1 && FD_NUMBERS.has(fullCmd)) return true;
|
|
796
|
+
|
|
797
|
+
// Check if this is a common redirection target (e.g., /dev/null)
|
|
798
|
+
if (REDIRECTION_TARGETS.has(fullCmd)) return true;
|
|
799
|
+
|
|
800
|
+
// Check conditional write commands (find with -exec, xargs, tee with files)
|
|
801
|
+
const conditionalCheck = CONDITIONAL_WRITE_COMMANDS[cmd];
|
|
802
|
+
if (conditionalCheck) {
|
|
803
|
+
// If the command would write/execute, it's not minimal level
|
|
804
|
+
if (conditionalCheck(tokens)) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
// Otherwise it's safe (e.g., find without -exec, tee to /dev/null)
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Basic read-only commands
|
|
812
|
+
if (MINIMAL_COMMANDS.has(cmd)) return true;
|
|
813
|
+
|
|
814
|
+
// Version checks
|
|
815
|
+
if (tokens.includes("--version") || tokens.includes("-v") || tokens.includes("-V")) {
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Git read operations
|
|
820
|
+
if (cmd === "git" && subCmd && MINIMAL_GIT_SUBCOMMANDS.has(subCmd)) {
|
|
821
|
+
// Some git commands are only read-only without additional args
|
|
822
|
+
// e.g., "git branch" lists branches (minimal), "git branch new" creates (medium)
|
|
823
|
+
// e.g., "git tag" lists tags (minimal), "git tag v1.0" creates (medium)
|
|
824
|
+
const READ_ONLY_WITHOUT_ARGS = new Set(["branch", "tag", "remote"]);
|
|
825
|
+
if (READ_ONLY_WITHOUT_ARGS.has(subCmd)) {
|
|
826
|
+
// Check if there are args beyond flags (starting with -)
|
|
827
|
+
const nonFlagArgs = tokens.slice(2).filter(t => !t.startsWith("-"));
|
|
828
|
+
if (nonFlagArgs.length > 0) {
|
|
829
|
+
return false; // Has args, not read-only
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Package manager read operations
|
|
836
|
+
if (MINIMAL_PACKAGE_SUBCOMMANDS[cmd]?.has(subCmd)) {
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// MEDIUM level - build/install/test operations only (NOT running code)
|
|
844
|
+
const MEDIUM_PACKAGE_PATTERNS: Array<[string, RegExp]> = [
|
|
845
|
+
// Node.js - install, build, test only (NOT run/start/exec which execute arbitrary code)
|
|
846
|
+
["npm", /^(install|ci|add|remove|uninstall|update|rebuild|dedupe|prune|link|pack|test|build)$/],
|
|
847
|
+
["yarn", /^(install|add|remove|upgrade|import|link|pack|test|build)$/],
|
|
848
|
+
["pnpm", /^(install|add|remove|update|link|pack|test|build)$/],
|
|
849
|
+
["bun", /^(install|add|remove|update|link|test|build)$/],
|
|
850
|
+
// npx/bunx/pnpx run arbitrary packages - HIGH (not included here)
|
|
851
|
+
|
|
852
|
+
// Python - install/build only (NOT running scripts)
|
|
853
|
+
["pip", /^install$/],
|
|
854
|
+
["pip3", /^install$/],
|
|
855
|
+
["pipenv", /^(install|update|sync|lock|uninstall)$/],
|
|
856
|
+
["poetry", /^(install|add|remove|update|lock|build)$/],
|
|
857
|
+
["conda", /^(install|update|remove|create)$/],
|
|
858
|
+
["uv", /^(pip|sync|lock)$/],
|
|
859
|
+
// python/python3 run arbitrary code - HIGH (not included here)
|
|
860
|
+
["pytest", /./], // test runner is safe
|
|
861
|
+
|
|
862
|
+
// Rust - build/test/lint only (NOT cargo run)
|
|
863
|
+
["cargo", /^(install|add|remove|fetch|update|build|test|check|clippy|fmt|doc|bench|clean)$/],
|
|
864
|
+
["rustfmt", /./],
|
|
865
|
+
// rustc compiles but doesn't run - medium
|
|
866
|
+
["rustc", /./],
|
|
867
|
+
|
|
868
|
+
// Go - build/test only (NOT go run)
|
|
869
|
+
["go", /^(get|mod|build|test|generate|fmt|vet|clean|install)$/],
|
|
870
|
+
|
|
871
|
+
// Ruby - install/build only
|
|
872
|
+
["gem", /^install$/],
|
|
873
|
+
["bundle", /^(install|update|add|remove|binstubs)$/],
|
|
874
|
+
["bundler", /^(install|update|add|remove)$/],
|
|
875
|
+
// CocoaPods - dependency management only
|
|
876
|
+
["pod", /^(install|update|repo)$/],
|
|
877
|
+
// rake/rails can run arbitrary code - HIGH (not included here)
|
|
878
|
+
["rspec", /./], // test runner
|
|
879
|
+
|
|
880
|
+
// PHP - install only
|
|
881
|
+
["composer", /^(install|require|remove|update|dump-autoload)$/],
|
|
882
|
+
// php runs code - HIGH (not included here)
|
|
883
|
+
["phpunit", /./], // test runner
|
|
884
|
+
|
|
885
|
+
// Java/Kotlin - compile/test only (NOT run)
|
|
886
|
+
["mvn", /^(install|compile|test|package|clean|dependency|verify)$/],
|
|
887
|
+
["gradle", /^(build|test|clean|assemble|dependencies|check)$/],
|
|
888
|
+
// gradlew can run arbitrary tasks - HIGH (not included here)
|
|
889
|
+
|
|
890
|
+
// .NET - build/test only (NOT run/watch)
|
|
891
|
+
["dotnet", /^(restore|add|build|test|clean|publish|pack|new)$/],
|
|
892
|
+
["nuget", /^install$/],
|
|
893
|
+
|
|
894
|
+
// Dart/Flutter - build/test only (NOT run)
|
|
895
|
+
["dart", /^(pub|compile|test|analyze|format|fix)$/],
|
|
896
|
+
["flutter", /^(pub|build|test|analyze|clean|create|doctor)$/],
|
|
897
|
+
["pub", /^(get|upgrade|downgrade|cache|deps)$/],
|
|
898
|
+
|
|
899
|
+
// Swift - build/test only (NOT run)
|
|
900
|
+
["swift", /^(package|build|test)$/],
|
|
901
|
+
["swiftc", /./],
|
|
902
|
+
|
|
903
|
+
// Elixir - build/test only (NOT run)
|
|
904
|
+
["mix", /^(deps|compile|test|ecto|phx\.gen)$/],
|
|
905
|
+
// elixir runs code - HIGH (not included here)
|
|
906
|
+
|
|
907
|
+
// Haskell - build/test only (NOT run)
|
|
908
|
+
["cabal", /^(install|build|test|update)$/],
|
|
909
|
+
["stack", /^(install|build|test|setup)$/],
|
|
910
|
+
// ghc compiles but doesn't run - medium
|
|
911
|
+
["ghc", /./],
|
|
912
|
+
|
|
913
|
+
// Others
|
|
914
|
+
["nimble", /^install$/],
|
|
915
|
+
["zig", /^(build|test|fetch)$/],
|
|
916
|
+
["cmake", /./],
|
|
917
|
+
["make", /./],
|
|
918
|
+
["ninja", /./],
|
|
919
|
+
["meson", /./],
|
|
920
|
+
|
|
921
|
+
// Linters/formatters - static analysis only (MEDIUM)
|
|
922
|
+
["eslint", /./],
|
|
923
|
+
["prettier", /./],
|
|
924
|
+
["black", /./],
|
|
925
|
+
["flake8", /./],
|
|
926
|
+
["pylint", /./],
|
|
927
|
+
["ruff", /./],
|
|
928
|
+
["pyflakes", /./],
|
|
929
|
+
["bandit", /./],
|
|
930
|
+
["mypy", /./],
|
|
931
|
+
["pyright", /./],
|
|
932
|
+
["tsc", /./],
|
|
933
|
+
["tslint", /./],
|
|
934
|
+
["standard", /./],
|
|
935
|
+
["xo", /./],
|
|
936
|
+
["rubocop", /./],
|
|
937
|
+
["standardrb", /./],
|
|
938
|
+
["reek", /./],
|
|
939
|
+
["brakeman", /./],
|
|
940
|
+
["golangci-lint", /./],
|
|
941
|
+
["gofmt", /./],
|
|
942
|
+
["go vet", /./],
|
|
943
|
+
["golint", /./],
|
|
944
|
+
["staticcheck", /./],
|
|
945
|
+
["errcheck", /./],
|
|
946
|
+
["misspell", /./],
|
|
947
|
+
["swiftlint", /./],
|
|
948
|
+
["swiftformat", /./],
|
|
949
|
+
["ktlint", /./],
|
|
950
|
+
["detekt", /./],
|
|
951
|
+
["dartanalyzer", /./], // dart analyze alternative name
|
|
952
|
+
["dartfmt", /./],
|
|
953
|
+
["clang-tidy", /./],
|
|
954
|
+
["clang-format", /./],
|
|
955
|
+
["cppcheck", /./],
|
|
956
|
+
["checkstyle", /./],
|
|
957
|
+
["pmd", /./],
|
|
958
|
+
["spotbugs", /./],
|
|
959
|
+
["sonarqube", /./],
|
|
960
|
+
["phpcs", /./],
|
|
961
|
+
["phpmd", /./],
|
|
962
|
+
["phpstan", /./],
|
|
963
|
+
["psalm", /./],
|
|
964
|
+
["php-cs-fixer", /./],
|
|
965
|
+
["luacheck", /./],
|
|
966
|
+
["shellcheck", /./],
|
|
967
|
+
["checkov", /./],
|
|
968
|
+
["tflint", /./],
|
|
969
|
+
["buf", /./], // protobuf linter
|
|
970
|
+
["sqlfluff", /./],
|
|
971
|
+
["yamllint", /./],
|
|
972
|
+
["markdownlint", /./],
|
|
973
|
+
["djlint", /./],
|
|
974
|
+
["djhtml", /./],
|
|
975
|
+
["commitlint", /./],
|
|
976
|
+
|
|
977
|
+
// Test runners
|
|
978
|
+
["jest", /./],
|
|
979
|
+
["mocha", /./],
|
|
980
|
+
["vitest", /./],
|
|
981
|
+
|
|
982
|
+
// File ops
|
|
983
|
+
["mkdir", /./],
|
|
984
|
+
["touch", /./],
|
|
985
|
+
["cp", /./],
|
|
986
|
+
["mv", /./],
|
|
987
|
+
["ln", /./],
|
|
988
|
+
|
|
989
|
+
// Database (local dev)
|
|
990
|
+
["prisma", /^(generate|migrate|db|studio)$/],
|
|
991
|
+
["sequelize", /^(db|migration)$/],
|
|
992
|
+
["typeorm", /^(migration)$/],
|
|
993
|
+
];
|
|
994
|
+
|
|
995
|
+
const MEDIUM_GIT_SUBCOMMANDS = new Set([
|
|
996
|
+
"add", "commit", "pull", "checkout", "switch", "branch",
|
|
997
|
+
"merge", "rebase", "cherry-pick", "stash", "revert", "tag",
|
|
998
|
+
"rm", "mv", "reset", "clone", // reset without --hard, clone is reversible
|
|
999
|
+
// NOT included (irreversible):
|
|
1000
|
+
// - clean: permanently deletes untracked files
|
|
1001
|
+
// - restore: can discard uncommitted changes permanently
|
|
1002
|
+
]);
|
|
1003
|
+
|
|
1004
|
+
// Safe npm/yarn/pnpm/bun run scripts (build, test, lint - not dev, start, serve)
|
|
1005
|
+
const SAFE_RUN_SCRIPTS = new Set([
|
|
1006
|
+
"build", "compile", "test", "lint", "format", "fmt", "check", "typecheck",
|
|
1007
|
+
"type-check", "types", "validate", "verify", "prepare", "prepublish",
|
|
1008
|
+
"prepublishOnly", "prepack", "postpack", "clean", "lint:fix", "format:check",
|
|
1009
|
+
"build:prod", "build:dev", "build:production", "build:development",
|
|
1010
|
+
"test:unit", "test:integration", "test:e2e", "test:coverage",
|
|
1011
|
+
]);
|
|
1012
|
+
|
|
1013
|
+
// Scripts that run servers or arbitrary code
|
|
1014
|
+
const UNSAFE_RUN_SCRIPTS = new Set([
|
|
1015
|
+
"start", "dev", "develop", "serve", "server", "watch", "preview",
|
|
1016
|
+
"start:dev", "start:prod", "dev:server",
|
|
1017
|
+
]);
|
|
1018
|
+
|
|
1019
|
+
function isSafeRunScript(script: string): boolean {
|
|
1020
|
+
const s = script.toLowerCase();
|
|
1021
|
+
// Check explicit safe list
|
|
1022
|
+
if (SAFE_RUN_SCRIPTS.has(s)) return true;
|
|
1023
|
+
// Check if starts with safe prefix
|
|
1024
|
+
if (s.startsWith("build") || s.startsWith("test") || s.startsWith("lint") ||
|
|
1025
|
+
s.startsWith("format") || s.startsWith("check") || s.startsWith("type")) {
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
// Check explicit unsafe list
|
|
1029
|
+
if (UNSAFE_RUN_SCRIPTS.has(s)) return false;
|
|
1030
|
+
// Check unsafe prefixes
|
|
1031
|
+
if (s.startsWith("start") || s.startsWith("dev") || s.startsWith("serve") ||
|
|
1032
|
+
s.startsWith("watch")) {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
// Default: unknown scripts are unsafe
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function isMediumLevel(tokens: string[]): boolean {
|
|
1040
|
+
if (tokens.length === 0) return false;
|
|
1041
|
+
|
|
1042
|
+
const cmd = getCommandName(tokens);
|
|
1043
|
+
const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : "";
|
|
1044
|
+
const thirdArg = tokens.length > 2 ? tokens[2] : "";
|
|
1045
|
+
|
|
1046
|
+
// Git local operations (not push)
|
|
1047
|
+
if (cmd === "git") {
|
|
1048
|
+
if (subCmd === "push") return false; // push is HIGH
|
|
1049
|
+
if (subCmd === "reset" && tokens.includes("--hard")) return false; // hard reset is HIGH
|
|
1050
|
+
if (MEDIUM_GIT_SUBCOMMANDS.has(subCmd)) return true;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Handle npm/yarn/pnpm/bun run <script> specially
|
|
1054
|
+
if (["npm", "yarn", "pnpm", "bun"].includes(cmd) && subCmd === "run") {
|
|
1055
|
+
// Need a script name
|
|
1056
|
+
if (!thirdArg || thirdArg.startsWith("-")) return false;
|
|
1057
|
+
return isSafeRunScript(thirdArg);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Package managers and build tools
|
|
1061
|
+
for (const [pattern, subPattern] of MEDIUM_PACKAGE_PATTERNS) {
|
|
1062
|
+
if (cmd === pattern) {
|
|
1063
|
+
if (!subCmd || subPattern.test(subCmd)) {
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// HIGH level - git push, remote operations
|
|
1073
|
+
function isHighLevel(tokens: string[]): boolean {
|
|
1074
|
+
if (tokens.length === 0) return false;
|
|
1075
|
+
|
|
1076
|
+
const cmd = getCommandName(tokens);
|
|
1077
|
+
const subCmd = tokens.length > 1 ? tokens[1].toLowerCase() : "";
|
|
1078
|
+
const argsStr = tokens.slice(1).join(" ");
|
|
1079
|
+
|
|
1080
|
+
// Git push
|
|
1081
|
+
if (cmd === "git" && subCmd === "push") return true;
|
|
1082
|
+
|
|
1083
|
+
// Git reset --hard
|
|
1084
|
+
if (cmd === "git" && subCmd === "reset" && tokens.includes("--hard")) return true;
|
|
1085
|
+
|
|
1086
|
+
// curl/wget piped to shell (detected at pipeline level)
|
|
1087
|
+
if (cmd === "curl" || cmd === "wget") return true;
|
|
1088
|
+
|
|
1089
|
+
// Running remote scripts
|
|
1090
|
+
if (cmd === "bash" || cmd === "sh" || cmd === "zsh") {
|
|
1091
|
+
if (argsStr.includes("http://") || argsStr.includes("https://")) return true;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Docker operations
|
|
1095
|
+
if (cmd === "docker" && ["push", "login", "logout"].includes(subCmd)) return true;
|
|
1096
|
+
|
|
1097
|
+
// Deployment tools
|
|
1098
|
+
if (["kubectl", "helm", "terraform", "pulumi", "ansible"].includes(cmd)) return true;
|
|
1099
|
+
|
|
1100
|
+
// SSH/SCP
|
|
1101
|
+
if (["ssh", "scp", "rsync"].includes(cmd)) return true;
|
|
1102
|
+
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ============================================================================
|
|
1107
|
+
// CLASSIFY COMMAND
|
|
1108
|
+
// ============================================================================
|
|
1109
|
+
|
|
1110
|
+
function classifySegment(tokens: string[]): Classification {
|
|
1111
|
+
if (tokens.length === 0) {
|
|
1112
|
+
return { level: "minimal", dangerous: false };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const cmd = getCommandName(tokens);
|
|
1116
|
+
|
|
1117
|
+
// Shell execution commands that can run arbitrary code - always HIGH
|
|
1118
|
+
// These bypass normal command classification since they execute their arguments
|
|
1119
|
+
if (SHELL_EXECUTION_COMMANDS.has(cmd)) {
|
|
1120
|
+
return { level: "high", dangerous: false };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (isDangerousCommand(tokens)) {
|
|
1124
|
+
return { level: "high", dangerous: true };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (isMinimalLevel(tokens)) {
|
|
1128
|
+
return { level: "minimal", dangerous: false };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (isMediumLevel(tokens)) {
|
|
1132
|
+
return { level: "medium", dangerous: false };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (isHighLevel(tokens)) {
|
|
1136
|
+
return { level: "high", dangerous: false };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Default: require HIGH for unknown commands
|
|
1140
|
+
return { level: "high", dangerous: false };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function classifyCommand(command: string, config?: PermissionConfig): Classification {
|
|
1144
|
+
// Load config if not provided (for testing)
|
|
1145
|
+
const effectiveConfig = config ?? getCachedConfig();
|
|
1146
|
+
|
|
1147
|
+
// Step 1: Apply prefix normalization
|
|
1148
|
+
const normalizedCommand = applyPrefixMappings(command, effectiveConfig.prefixMappings);
|
|
1149
|
+
|
|
1150
|
+
const parsed = parseCommand(normalizedCommand);
|
|
1151
|
+
|
|
1152
|
+
// If command contains shell tricks (command substitution, backticks, etc.),
|
|
1153
|
+
// require HIGH level as we cannot reliably classify the embedded commands
|
|
1154
|
+
if (parsed.hasShellTricks) {
|
|
1155
|
+
return { level: "high", dangerous: false };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Step 2: Check for override on NORMALIZED command (consistent with classification)
|
|
1159
|
+
const override = checkOverrides(normalizedCommand, effectiveConfig.overrides);
|
|
1160
|
+
if (override) {
|
|
1161
|
+
return override;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
let maxLevel: PermissionLevel = "minimal";
|
|
1165
|
+
let dangerous = false;
|
|
1166
|
+
|
|
1167
|
+
// If command writes to files via redirection (>, >>), require at least LOW
|
|
1168
|
+
if (parsed.writesFiles) {
|
|
1169
|
+
maxLevel = "low";
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
for (let i = 0; i < parsed.segments.length; i++) {
|
|
1173
|
+
const segment = parsed.segments[i];
|
|
1174
|
+
const segmentClass = classifySegment(segment);
|
|
1175
|
+
|
|
1176
|
+
if (segmentClass.dangerous) {
|
|
1177
|
+
dangerous = true;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (LEVEL_INDEX[segmentClass.level] > LEVEL_INDEX[maxLevel]) {
|
|
1181
|
+
maxLevel = segmentClass.level;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Check for piping to shell
|
|
1185
|
+
if (i < parsed.segments.length - 1 && parsed.operators[i] === "|") {
|
|
1186
|
+
const nextCmd = getCommandName(parsed.segments[i + 1]);
|
|
1187
|
+
if (["bash", "sh", "zsh", "node", "python", "python3", "ruby", "perl"].includes(nextCmd)) {
|
|
1188
|
+
maxLevel = "high";
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
return { level: maxLevel, dangerous };
|
|
1194
|
+
}
|