@geotechcli/core 0.4.20 → 0.4.21

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.
@@ -1,24 +1,29 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Filesystem Sandbox path traversal prevention
2
+ // Filesystem sandbox and command hardening for agent tools.
3
3
  //
4
- // All agent filesystem tools MUST validate paths through this module.
4
+ // All filesystem and shell tools MUST validate paths through this module.
5
5
  // Allowed zones:
6
6
  // 1. Current working directory (and children)
7
7
  // 2. ~/.geotechcli/workspace/ (persistent project data)
8
8
  //
9
9
  // Blocked patterns:
10
- // - Paths containing .ssh, .aws, .gnupg, .env files, credentials
11
- // - Paths outside allowed zones (even via symlink resolution)
12
- // - /etc, /var, /usr, /proc, /sys, /dev system directories
10
+ // - Paths containing credentials or private-key markers
11
+ // - Paths outside allowed zones (including symlink escapes)
12
+ // - System directories
13
+ // - geotechCLI implementation internals
13
14
  // ---------------------------------------------------------------------------
14
- import { dirname, resolve, sep } from 'node:path';
15
- import { homedir } from 'node:os';
16
15
  import { existsSync, mkdirSync, realpathSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join, resolve, sep } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
17
19
  // ---------------------------------------------------------------------------
18
20
  // Constants
19
21
  // ---------------------------------------------------------------------------
20
22
  const GEOTECHCLI_DIR = process.env.GEOTECHCLI_CONFIG_DIR ?? `${homedir()}${sep}.geotechcli`;
21
23
  const WORKSPACE_DIR = `${GEOTECHCLI_DIR}${sep}workspace`;
24
+ const SANDBOX_MODULE_DIR = dirname(fileURLToPath(import.meta.url));
25
+ const CORE_PACKAGE_ROOT = resolve(SANDBOX_MODULE_DIR, '..', '..');
26
+ const MONOREPO_ROOT_CANDIDATE = resolve(SANDBOX_MODULE_DIR, '..', '..', '..', '..');
22
27
  const SENSITIVE_PATTERNS = [
23
28
  '.ssh',
24
29
  '.aws',
@@ -61,6 +66,11 @@ const BLOCKED_SYSTEM_PREFIXES = [
61
66
  function normalizeForPrefixCheck(value) {
62
67
  return value.replace(/\\/g, '/').toLowerCase();
63
68
  }
69
+ function isPathEqualOrWithin(targetPath, parentPath) {
70
+ const normalizedTarget = normalizeForPrefixCheck(resolve(targetPath));
71
+ const normalizedParent = normalizeForPrefixCheck(resolve(parentPath));
72
+ return normalizedTarget === normalizedParent || normalizedTarget.startsWith(`${normalizedParent}/`);
73
+ }
64
74
  function isWithinAllowedZones(targetPath, allowedZones) {
65
75
  return allowedZones.some((zone) => targetPath.startsWith(zone + sep) || targetPath === zone);
66
76
  }
@@ -76,6 +86,76 @@ function findNearestExistingParent(targetPath) {
76
86
  }
77
87
  return existsSync(current) ? current : null;
78
88
  }
89
+ function isGeotechCliMonorepoRoot(targetPath) {
90
+ return existsSync(join(targetPath, 'packages', 'core')) && existsSync(join(targetPath, 'package.json'));
91
+ }
92
+ function addInternalPathRule(rules, targetPath, reason) {
93
+ rules.push({
94
+ path: resolve(targetPath),
95
+ reason,
96
+ });
97
+ }
98
+ function buildInternalPathRules() {
99
+ const rules = [];
100
+ addInternalPathRule(rules, join(CORE_PACKAGE_ROOT, 'src'), 'geotechCLI core source tree');
101
+ addInternalPathRule(rules, join(CORE_PACKAGE_ROOT, 'dist'), 'geotechCLI published runtime bundle');
102
+ addInternalPathRule(rules, join(CORE_PACKAGE_ROOT, 'tests'), 'geotechCLI core test fixtures');
103
+ addInternalPathRule(rules, join(CORE_PACKAGE_ROOT, 'bundled-skills'), 'geotechCLI bundled skill assets');
104
+ addInternalPathRule(rules, join(CORE_PACKAGE_ROOT, 'node_modules'), 'geotechCLI package dependencies');
105
+ addInternalPathRule(rules, join(CORE_PACKAGE_ROOT, '.turbo'), 'geotechCLI build cache');
106
+ if (isGeotechCliMonorepoRoot(MONOREPO_ROOT_CANDIDATE)) {
107
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, '.git'), 'geotechCLI git metadata');
108
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, '.github'), 'geotechCLI repository automation');
109
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, '.changeset'), 'geotechCLI release metadata');
110
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'modal'), 'geotechCLI deployment internals');
111
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'scripts'), 'geotechCLI release scripts');
112
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'supabase'), 'geotechCLI infrastructure configuration');
113
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'node_modules'), 'geotechCLI monorepo dependencies');
114
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, '__agent-skill-mock'), 'geotechCLI internal skill fixtures');
115
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'AGENTS.md'), 'geotechCLI internal repo instructions');
116
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'CLAUDE.md'), 'geotechCLI internal repo instructions');
117
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'STRONG_BETA_HANDOFF.md'), 'geotechCLI internal handoff notes');
118
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'STRONG_BETA_SKILLS_CERTIFICATION.md'), 'geotechCLI internal certification notes');
119
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'STRONG_BETA_SKILLS_POLICY.md'), 'geotechCLI internal policy notes');
120
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'cli', 'src'), 'geotechCLI CLI source tree');
121
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'cli', 'dist'), 'geotechCLI CLI build output');
122
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'cli', 'tests'), 'geotechCLI CLI test fixtures');
123
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'cli', '.turbo'), 'geotechCLI CLI build cache');
124
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', 'app'), 'geotechCLI website source tree');
125
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', 'components'), 'geotechCLI website source tree');
126
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', 'lib'), 'geotechCLI website source tree');
127
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', '.next'), 'geotechCLI website build output');
128
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', '.open-next'), 'geotechCLI website deployment bundle');
129
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', 'node_modules'), 'geotechCLI website dependencies');
130
+ addInternalPathRule(rules, join(MONOREPO_ROOT_CANDIDATE, 'packages', 'web', '.turbo'), 'geotechCLI website build cache');
131
+ }
132
+ return rules;
133
+ }
134
+ const INTERNAL_PATH_RULES = buildInternalPathRules();
135
+ function findBlockedInternalPath(targetPath) {
136
+ return INTERNAL_PATH_RULES.find((rule) => isPathEqualOrWithin(targetPath, rule.path));
137
+ }
138
+ function hasBlockedInternalDescendant(targetPath) {
139
+ const normalizedTarget = normalizeForPrefixCheck(resolve(targetPath));
140
+ return INTERNAL_PATH_RULES.some((rule) => {
141
+ const normalizedRulePath = normalizeForPrefixCheck(rule.path);
142
+ return normalizedRulePath.startsWith(`${normalizedTarget}/`);
143
+ });
144
+ }
145
+ export function validateEnumerationPath(targetPath, extraAllowed) {
146
+ const check = validateReadPath(targetPath, extraAllowed);
147
+ if (!check.safe) {
148
+ return check;
149
+ }
150
+ if (hasBlockedInternalDescendant(check.resolved)) {
151
+ return {
152
+ safe: false,
153
+ resolved: check.resolved,
154
+ error: `Access denied: "${check.resolved}" is too broad because it contains blocked geotechCLI internals. Scope the request to a safe project subdirectory instead.`,
155
+ };
156
+ }
157
+ return check;
158
+ }
79
159
  // ---------------------------------------------------------------------------
80
160
  // Ensure workspace exists
81
161
  // ---------------------------------------------------------------------------
@@ -91,23 +171,10 @@ export function getWorkspaceDir() {
91
171
  // ---------------------------------------------------------------------------
92
172
  // Core validation
93
173
  // ---------------------------------------------------------------------------
94
- /**
95
- * Validate that a path is inside an allowed zone and does not target
96
- * sensitive files. Call this BEFORE any read/write/list operation.
97
- *
98
- * Allowed zones:
99
- * - process.cwd() and its children
100
- * - WORKSPACE_DIR and its children
101
- * - Any additional directories passed in `extraAllowed`
102
- *
103
- * For WRITE operations, pass mode='write' to additionally block
104
- * the config directory itself (only workspace subdir is writable).
105
- */
106
174
  export function validatePath(targetPath, mode = 'read', extraAllowed) {
107
175
  const resolved = resolve(targetPath);
108
176
  const normalizedInput = normalizeForPrefixCheck(targetPath);
109
177
  const normalizedResolved = normalizeForPrefixCheck(resolved);
110
- // --- 1. Block system directories ---
111
178
  for (const prefix of BLOCKED_SYSTEM_PREFIXES) {
112
179
  const normalizedPrefix = normalizeForPrefixCheck(prefix);
113
180
  const matchesSystemPrefix = normalizedInput === normalizedPrefix ||
@@ -122,13 +189,11 @@ export function validatePath(targetPath, mode = 'read', extraAllowed) {
122
189
  };
123
190
  }
124
191
  }
125
- // --- 2. Block sensitive file patterns ---
126
192
  const pathParts = resolved.split(sep);
127
193
  for (const part of pathParts) {
128
194
  const partLower = part.toLowerCase();
129
195
  for (const pattern of SENSITIVE_PATTERNS) {
130
196
  if (partLower === pattern.toLowerCase() || partLower.includes(pattern.toLowerCase())) {
131
- // Exception: allow reading .geotechcli config (but not writing to it directly)
132
197
  if (resolved.startsWith(GEOTECHCLI_DIR) && mode === 'read') {
133
198
  continue;
134
199
  }
@@ -140,15 +205,8 @@ export function validatePath(targetPath, mode = 'read', extraAllowed) {
140
205
  }
141
206
  }
142
207
  }
143
- // --- 3. Check allowed zones ---
144
208
  const cwd = process.cwd();
145
- const allowedZones = [
146
- cwd,
147
- WORKSPACE_DIR,
148
- ...(extraAllowed ?? []),
149
- ].map((z) => resolve(z));
150
- // For read: allow CWD, workspace, and extra
151
- // For write: allow CWD and workspace only
209
+ const allowedZones = [cwd, WORKSPACE_DIR, ...(extraAllowed ?? [])].map((zone) => resolve(zone));
152
210
  const isInAllowedZone = isWithinAllowedZones(resolved, allowedZones);
153
211
  if (!isInAllowedZone) {
154
212
  return {
@@ -157,7 +215,14 @@ export function validatePath(targetPath, mode = 'read', extraAllowed) {
157
215
  error: `Access denied: "${resolved}" is outside allowed directories. Allowed: ${allowedZones.join(', ')}`,
158
216
  };
159
217
  }
160
- // --- 4. Resolve symlinks and re-check (prevent symlink escape) ---
218
+ const internalPath = findBlockedInternalPath(resolved);
219
+ if (internalPath) {
220
+ return {
221
+ safe: false,
222
+ resolved,
223
+ error: `Access denied: "${resolved}" is part of ${internalPath.reason} and is not available through agent filesystem tools.`,
224
+ };
225
+ }
161
226
  if (existsSync(resolved)) {
162
227
  try {
163
228
  const realPath = realpathSync(resolved);
@@ -169,9 +234,17 @@ export function validatePath(targetPath, mode = 'read', extraAllowed) {
169
234
  error: `Access denied: symlink resolves to "${realPath}" which is outside allowed directories.`,
170
235
  };
171
236
  }
237
+ const realInternalPath = findBlockedInternalPath(realPath);
238
+ if (realInternalPath) {
239
+ return {
240
+ safe: false,
241
+ resolved,
242
+ error: `Access denied: symlink resolves to "${realPath}" which is part of ${realInternalPath.reason}.`,
243
+ };
244
+ }
172
245
  }
173
246
  catch {
174
- // Can't resolve symlink proceed with caution (file may not exist yet for writes)
247
+ // The target may not be fully materialized yet for a write.
175
248
  }
176
249
  }
177
250
  if (mode === 'write' && !existsSync(resolved)) {
@@ -186,6 +259,14 @@ export function validatePath(targetPath, mode = 'read', extraAllowed) {
186
259
  error: `Access denied: parent symlink resolves to "${realParent}" which is outside allowed directories.`,
187
260
  };
188
261
  }
262
+ const realParentInternalPath = findBlockedInternalPath(realParent);
263
+ if (realParentInternalPath) {
264
+ return {
265
+ safe: false,
266
+ resolved,
267
+ error: `Access denied: parent symlink resolves to "${realParent}" which is part of ${realParentInternalPath.reason}.`,
268
+ };
269
+ }
189
270
  }
190
271
  catch {
191
272
  // Keep the earlier prefix and zone checks if parent realpath cannot be resolved.
@@ -194,27 +275,35 @@ export function validatePath(targetPath, mode = 'read', extraAllowed) {
194
275
  }
195
276
  return { safe: true, resolved };
196
277
  }
197
- /**
198
- * Convenience: validate for reading.
199
- */
200
278
  export function validateReadPath(targetPath, extraAllowed) {
201
279
  return validatePath(targetPath, 'read', extraAllowed);
202
280
  }
203
- /**
204
- * Convenience: validate for writing.
205
- */
206
281
  export function validateWritePath(targetPath, extraAllowed) {
207
282
  return validatePath(targetPath, 'write', extraAllowed);
208
283
  }
209
- /**
210
- * Validate a shell command for safety.
211
- * Returns the sanitized command or an error.
212
- */
213
- export function validateShellCommand(command) {
284
+ // ---------------------------------------------------------------------------
285
+ // Shell command validation
286
+ // ---------------------------------------------------------------------------
287
+ export function validateShellCommand(command, options) {
214
288
  const trimmed = command.trim();
289
+ const commandCwd = resolve(options?.cwd ?? process.cwd());
290
+ if (!trimmed) {
291
+ return {
292
+ safe: false,
293
+ resolved: trimmed,
294
+ error: 'Command is empty.',
295
+ };
296
+ }
297
+ if (/[\r\n]/.test(trimmed)) {
298
+ return {
299
+ safe: false,
300
+ resolved: trimmed,
301
+ error: 'Command contains blocked operator "newline". Multi-line shell input is not allowed.',
302
+ };
303
+ }
215
304
  const parts = trimmed.split(/\s+/);
216
305
  const firstWord = parts[0];
217
- // Allowed read-only commands
306
+ const remaining = parts.slice(1);
218
307
  const allowedCommands = ['ls', 'cat', 'head', 'tail', 'wc', 'find', 'grep', 'file', 'stat', 'du', 'pwd', 'echo'];
219
308
  if (!allowedCommands.includes(firstWord)) {
220
309
  return {
@@ -223,29 +312,46 @@ export function validateShellCommand(command) {
223
312
  error: `Command "${firstWord}" not allowed. Permitted: ${allowedCommands.join(', ')}`,
224
313
  };
225
314
  }
226
- // Block dangerous operators in any command
227
315
  const blockedOperators = [
228
- '<', '>', '>>', '|', '&&', '||', ';', '$(', '`',
229
- 'sudo', 'chmod', 'chown', 'chgrp',
230
- 'rm ', 'rm\t', 'rmdir',
231
- 'mv ', 'mv\t',
232
- 'cp ', 'cp\t',
233
- 'mkfs', 'dd ',
234
- 'wget ', 'curl ',
235
- 'nc ', 'ncat ',
316
+ '<',
317
+ '>',
318
+ '>>',
319
+ '|',
320
+ '&&',
321
+ '||',
322
+ ';',
323
+ '$(',
324
+ '`',
325
+ 'sudo',
326
+ 'chmod',
327
+ 'chown',
328
+ 'chgrp',
329
+ 'rm ',
330
+ 'rm\t',
331
+ 'rmdir',
332
+ 'mv ',
333
+ 'mv\t',
334
+ 'cp ',
335
+ 'cp\t',
336
+ 'mkfs',
337
+ 'dd ',
338
+ 'wget ',
339
+ 'curl ',
340
+ 'nc ',
341
+ 'ncat ',
236
342
  ];
237
- for (const op of blockedOperators) {
238
- if (trimmed.includes(op)) {
343
+ for (const fragment of blockedOperators) {
344
+ if (trimmed.includes(fragment)) {
239
345
  return {
240
346
  safe: false,
241
347
  resolved: trimmed,
242
- error: `Command contains blocked operator "${op.trim()}". Pipes, redirects, chaining, and destructive commands are not allowed.`,
348
+ error: `Command contains blocked operator "${fragment.trim()}". Pipes, redirects, chaining, and destructive commands are not allowed.`,
243
349
  };
244
350
  }
245
351
  }
246
352
  if (firstWord === 'find') {
247
353
  const blockedFindPredicates = ['-exec', '-execdir', '-ok', '-delete'];
248
- for (const token of parts.slice(1)) {
354
+ for (const token of remaining) {
249
355
  if (blockedFindPredicates.some((predicate) => token === predicate || token.startsWith(predicate))) {
250
356
  return {
251
357
  safe: false,
@@ -255,33 +361,273 @@ export function validateShellCommand(command) {
255
361
  }
256
362
  }
257
363
  }
258
- const nonOptionTokens = parts.slice(1).filter((token) => !token.startsWith('-'));
259
- const validatePathToken = (token) => {
260
- if (/^\d+$/.test(token)) {
261
- return null;
364
+ const blockedShellExpansions = ['"', '\'', '*', '?', '[', ']', '{', '}', '$', '%', '!', '^', '(', ')'];
365
+ for (const fragment of blockedShellExpansions) {
366
+ if (trimmed.includes(fragment)) {
367
+ return {
368
+ safe: false,
369
+ resolved: trimmed,
370
+ error: `Command contains blocked shell expansion "${fragment}". Use simple literal arguments only.`,
371
+ };
262
372
  }
263
- const pathCheck = validateReadPath(token);
264
- return pathCheck.safe ? null : pathCheck;
265
- };
266
- let pathTokens = [];
267
- if (['ls', 'cat', 'head', 'tail', 'wc', 'file', 'stat', 'du'].includes(firstWord)) {
268
- pathTokens = nonOptionTokens;
269
- }
270
- else if (firstWord === 'grep') {
271
- pathTokens = nonOptionTokens.slice(1);
272
373
  }
273
- else if (firstWord === 'find' && nonOptionTokens.length > 0) {
274
- pathTokens = [nonOptionTokens[0]];
275
- }
276
- for (const token of pathTokens) {
277
- const pathCheck = validatePathToken(token);
278
- if (pathCheck && !pathCheck.safe) {
374
+ const buildPathError = (token, check) => ({
375
+ safe: false,
376
+ resolved: trimmed,
377
+ error: `Command path "${token}" is not allowed. ${check.error}`,
378
+ });
379
+ const validateReadOnlyPathToken = (token) => {
380
+ const pathCheck = validateReadPath(resolve(commandCwd, token));
381
+ return pathCheck.safe ? null : buildPathError(token, pathCheck);
382
+ };
383
+ const validateEnumeratedPathToken = (token) => {
384
+ const pathCheck = validateReadPath(resolve(commandCwd, token));
385
+ if (!pathCheck.safe) {
386
+ return buildPathError(token, pathCheck);
387
+ }
388
+ if (hasBlockedInternalDescendant(pathCheck.resolved)) {
279
389
  return {
280
390
  safe: false,
281
391
  resolved: trimmed,
282
- error: `Command path "${token}" is not allowed. ${pathCheck.error}`,
392
+ error: `Command path "${token}" is too broad because it would expose blocked geotechCLI internals. Scope the command to a safe project subdirectory instead.`,
283
393
  };
284
394
  }
395
+ return null;
396
+ };
397
+ const rejectUnsupportedOptions = (tokens, isAllowedOption) => {
398
+ for (const token of tokens) {
399
+ if (token.startsWith('-') && !isAllowedOption(token)) {
400
+ return {
401
+ safe: false,
402
+ resolved: trimmed,
403
+ error: `Command option "${token}" is not allowed for ${firstWord}.`,
404
+ };
405
+ }
406
+ }
407
+ return null;
408
+ };
409
+ switch (firstWord) {
410
+ case 'pwd':
411
+ if (remaining.length > 0) {
412
+ return {
413
+ safe: false,
414
+ resolved: trimmed,
415
+ error: 'pwd does not accept arguments in run_command.',
416
+ };
417
+ }
418
+ break;
419
+ case 'echo':
420
+ break;
421
+ case 'ls': {
422
+ const optionError = rejectUnsupportedOptions(remaining, (token) => /^-[al]+$/i.test(token));
423
+ if (optionError)
424
+ return optionError;
425
+ const pathTokens = remaining.filter((token) => !token.startsWith('-'));
426
+ if (pathTokens.length === 0) {
427
+ const cwdEnumerationCheck = validateEnumeratedPathToken(commandCwd);
428
+ if (cwdEnumerationCheck)
429
+ return cwdEnumerationCheck;
430
+ }
431
+ for (const token of pathTokens) {
432
+ const pathCheck = validateEnumeratedPathToken(token);
433
+ if (pathCheck)
434
+ return pathCheck;
435
+ }
436
+ break;
437
+ }
438
+ case 'cat':
439
+ case 'file':
440
+ case 'stat': {
441
+ if (remaining.length === 0) {
442
+ return {
443
+ safe: false,
444
+ resolved: trimmed,
445
+ error: `${firstWord} requires at least one explicit path.`,
446
+ };
447
+ }
448
+ const optionError = rejectUnsupportedOptions(remaining, () => false);
449
+ if (optionError)
450
+ return optionError;
451
+ for (const token of remaining) {
452
+ const pathCheck = validateReadOnlyPathToken(token);
453
+ if (pathCheck)
454
+ return pathCheck;
455
+ }
456
+ break;
457
+ }
458
+ case 'head':
459
+ case 'tail': {
460
+ const pathTokens = [];
461
+ for (let i = 0; i < remaining.length; i++) {
462
+ const token = remaining[i];
463
+ if (token === '-n') {
464
+ const countToken = remaining[i + 1];
465
+ if (!countToken || !/^\d+$/.test(countToken)) {
466
+ return {
467
+ safe: false,
468
+ resolved: trimmed,
469
+ error: `${firstWord} requires a numeric value after -n.`,
470
+ };
471
+ }
472
+ i += 1;
473
+ continue;
474
+ }
475
+ if (/^-\d+$/.test(token)) {
476
+ continue;
477
+ }
478
+ if (token.startsWith('-')) {
479
+ return {
480
+ safe: false,
481
+ resolved: trimmed,
482
+ error: `Command option "${token}" is not allowed for ${firstWord}.`,
483
+ };
484
+ }
485
+ pathTokens.push(token);
486
+ }
487
+ if (pathTokens.length === 0) {
488
+ return {
489
+ safe: false,
490
+ resolved: trimmed,
491
+ error: `${firstWord} requires at least one explicit path.`,
492
+ };
493
+ }
494
+ for (const token of pathTokens) {
495
+ const pathCheck = validateReadOnlyPathToken(token);
496
+ if (pathCheck)
497
+ return pathCheck;
498
+ }
499
+ break;
500
+ }
501
+ case 'wc': {
502
+ if (remaining.length === 0) {
503
+ return {
504
+ safe: false,
505
+ resolved: trimmed,
506
+ error: 'wc requires at least one explicit path.',
507
+ };
508
+ }
509
+ const optionError = rejectUnsupportedOptions(remaining, (token) => /^-[lwc]+$/i.test(token));
510
+ if (optionError)
511
+ return optionError;
512
+ const pathTokens = remaining.filter((token) => !token.startsWith('-'));
513
+ if (pathTokens.length === 0) {
514
+ return {
515
+ safe: false,
516
+ resolved: trimmed,
517
+ error: 'wc requires at least one explicit path.',
518
+ };
519
+ }
520
+ for (const token of pathTokens) {
521
+ const pathCheck = validateReadOnlyPathToken(token);
522
+ if (pathCheck)
523
+ return pathCheck;
524
+ }
525
+ break;
526
+ }
527
+ case 'du': {
528
+ const optionError = rejectUnsupportedOptions(remaining, (token) => /^-[sh]+$/i.test(token));
529
+ if (optionError)
530
+ return optionError;
531
+ const pathTokens = remaining.filter((token) => !token.startsWith('-'));
532
+ if (pathTokens.length === 0) {
533
+ const cwdEnumerationCheck = validateEnumeratedPathToken(commandCwd);
534
+ if (cwdEnumerationCheck)
535
+ return cwdEnumerationCheck;
536
+ }
537
+ for (const token of pathTokens) {
538
+ const pathCheck = validateEnumeratedPathToken(token);
539
+ if (pathCheck)
540
+ return pathCheck;
541
+ }
542
+ break;
543
+ }
544
+ case 'find': {
545
+ if (remaining.length === 0 || remaining[0].startsWith('-')) {
546
+ return {
547
+ safe: false,
548
+ resolved: trimmed,
549
+ error: 'find requires an explicit starting path.',
550
+ };
551
+ }
552
+ const rootPathCheck = validateEnumeratedPathToken(remaining[0]);
553
+ if (rootPathCheck)
554
+ return rootPathCheck;
555
+ for (let i = 1; i < remaining.length; i++) {
556
+ const token = remaining[i];
557
+ if (token === '-maxdepth') {
558
+ const depthToken = remaining[i + 1];
559
+ if (!depthToken || !/^\d+$/.test(depthToken)) {
560
+ return {
561
+ safe: false,
562
+ resolved: trimmed,
563
+ error: 'find requires a numeric value after -maxdepth.',
564
+ };
565
+ }
566
+ i += 1;
567
+ continue;
568
+ }
569
+ if (token === '-type') {
570
+ const typeToken = remaining[i + 1];
571
+ if (!typeToken || !['f', 'd'].includes(typeToken)) {
572
+ return {
573
+ safe: false,
574
+ resolved: trimmed,
575
+ error: 'find only allows -type f or -type d.',
576
+ };
577
+ }
578
+ i += 1;
579
+ continue;
580
+ }
581
+ if (token === '-name') {
582
+ const nameToken = remaining[i + 1];
583
+ if (!nameToken) {
584
+ return {
585
+ safe: false,
586
+ resolved: trimmed,
587
+ error: 'find requires a literal value after -name.',
588
+ };
589
+ }
590
+ i += 1;
591
+ continue;
592
+ }
593
+ return {
594
+ safe: false,
595
+ resolved: trimmed,
596
+ error: `Command option "${token}" is not allowed for find.`,
597
+ };
598
+ }
599
+ break;
600
+ }
601
+ case 'grep': {
602
+ const optionTokens = [];
603
+ const nonOptionTokens = [];
604
+ for (const token of remaining) {
605
+ if (token.startsWith('-') && nonOptionTokens.length === 0) {
606
+ optionTokens.push(token);
607
+ continue;
608
+ }
609
+ nonOptionTokens.push(token);
610
+ }
611
+ const optionError = rejectUnsupportedOptions(optionTokens, (token) => /^-[in]+$/i.test(token));
612
+ if (optionError)
613
+ return optionError;
614
+ if (nonOptionTokens.length < 2) {
615
+ return {
616
+ safe: false,
617
+ resolved: trimmed,
618
+ error: 'grep requires a literal search term and at least one explicit path.',
619
+ };
620
+ }
621
+ const [, ...pathTokens] = nonOptionTokens;
622
+ for (const token of pathTokens) {
623
+ const pathCheck = validateEnumeratedPathToken(token);
624
+ if (pathCheck)
625
+ return pathCheck;
626
+ }
627
+ break;
628
+ }
629
+ default:
630
+ break;
285
631
  }
286
632
  return { safe: true, resolved: trimmed };
287
633
  }