@geotechcli/core 0.4.19 → 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.
- package/dist/agents/brain.d.ts.map +1 -1
- package/dist/agents/brain.js +15 -0
- package/dist/agents/brain.js.map +1 -1
- package/dist/agents/filesystem-tools.js +11 -6
- package/dist/agents/filesystem-tools.js.map +1 -1
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +20 -6
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/agents/proprietary-internals.d.ts +4 -0
- package/dist/agents/proprietary-internals.d.ts.map +1 -0
- package/dist/agents/proprietary-internals.js +32 -0
- package/dist/agents/proprietary-internals.js.map +1 -0
- package/dist/agents/sandbox.d.ts +4 -23
- package/dist/agents/sandbox.d.ts.map +1 -1
- package/dist/agents/sandbox.js +423 -77
- package/dist/agents/sandbox.js.map +1 -1
- package/dist/agents/shell-tools.js +4 -4
- package/dist/agents/shell-tools.js.map +1 -1
- package/dist/agents/swarm.d.ts.map +1 -1
- package/dist/agents/swarm.js +29 -7
- package/dist/agents/swarm.js.map +1 -1
- package/dist/meta/metadata.json +1 -1
- package/package.json +1 -1
package/dist/agents/sandbox.js
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Filesystem
|
|
2
|
+
// Filesystem sandbox and command hardening for agent tools.
|
|
3
3
|
//
|
|
4
|
-
// All
|
|
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
|
|
11
|
-
// - Paths outside allowed zones (
|
|
12
|
-
// -
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
233
|
-
'
|
|
234
|
-
'
|
|
235
|
-
'
|
|
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
|
|
238
|
-
if (trimmed.includes(
|
|
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 "${
|
|
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
|
|
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
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
return
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
}
|