@animus-labs/cortex 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/budget-guard.d.ts +75 -0
- package/dist/budget-guard.d.ts.map +1 -0
- package/dist/budget-guard.js +142 -0
- package/dist/budget-guard.js.map +1 -0
- package/dist/compaction/compaction.d.ts +99 -0
- package/dist/compaction/compaction.d.ts.map +1 -0
- package/dist/compaction/compaction.js +302 -0
- package/dist/compaction/compaction.js.map +1 -0
- package/dist/compaction/failsafe.d.ts +57 -0
- package/dist/compaction/failsafe.d.ts.map +1 -0
- package/dist/compaction/failsafe.js +135 -0
- package/dist/compaction/failsafe.js.map +1 -0
- package/dist/compaction/index.d.ts +381 -0
- package/dist/compaction/index.d.ts.map +1 -0
- package/dist/compaction/index.js +979 -0
- package/dist/compaction/index.js.map +1 -0
- package/dist/compaction/microcompaction.d.ts +219 -0
- package/dist/compaction/microcompaction.d.ts.map +1 -0
- package/dist/compaction/microcompaction.js +536 -0
- package/dist/compaction/microcompaction.js.map +1 -0
- package/dist/compaction/observational/buffering.d.ts +225 -0
- package/dist/compaction/observational/buffering.d.ts.map +1 -0
- package/dist/compaction/observational/buffering.js +354 -0
- package/dist/compaction/observational/buffering.js.map +1 -0
- package/dist/compaction/observational/constants.d.ts +70 -0
- package/dist/compaction/observational/constants.d.ts.map +1 -0
- package/dist/compaction/observational/constants.js +507 -0
- package/dist/compaction/observational/constants.js.map +1 -0
- package/dist/compaction/observational/index.d.ts +219 -0
- package/dist/compaction/observational/index.d.ts.map +1 -0
- package/dist/compaction/observational/index.js +641 -0
- package/dist/compaction/observational/index.js.map +1 -0
- package/dist/compaction/observational/observer.d.ts +97 -0
- package/dist/compaction/observational/observer.d.ts.map +1 -0
- package/dist/compaction/observational/observer.js +424 -0
- package/dist/compaction/observational/observer.js.map +1 -0
- package/dist/compaction/observational/recall-tool.d.ts +27 -0
- package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
- package/dist/compaction/observational/recall-tool.js +93 -0
- package/dist/compaction/observational/recall-tool.js.map +1 -0
- package/dist/compaction/observational/reflector.d.ts +94 -0
- package/dist/compaction/observational/reflector.d.ts.map +1 -0
- package/dist/compaction/observational/reflector.js +167 -0
- package/dist/compaction/observational/reflector.js.map +1 -0
- package/dist/compaction/observational/types.d.ts +271 -0
- package/dist/compaction/observational/types.d.ts.map +1 -0
- package/dist/compaction/observational/types.js +15 -0
- package/dist/compaction/observational/types.js.map +1 -0
- package/dist/context-manager.d.ts +134 -0
- package/dist/context-manager.d.ts.map +1 -0
- package/dist/context-manager.js +170 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/cortex-agent.d.ts +1020 -0
- package/dist/cortex-agent.d.ts.map +1 -0
- package/dist/cortex-agent.js +3589 -0
- package/dist/cortex-agent.js.map +1 -0
- package/dist/error-classifier.d.ts +48 -0
- package/dist/error-classifier.d.ts.map +1 -0
- package/dist/error-classifier.js +152 -0
- package/dist/error-classifier.js.map +1 -0
- package/dist/event-bridge.d.ts +166 -0
- package/dist/event-bridge.d.ts.map +1 -0
- package/dist/event-bridge.js +381 -0
- package/dist/event-bridge.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-client.d.ts +119 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +474 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/model-wrapper.d.ts +58 -0
- package/dist/model-wrapper.d.ts.map +1 -0
- package/dist/model-wrapper.js +86 -0
- package/dist/model-wrapper.js.map +1 -0
- package/dist/noop-logger.d.ts +4 -0
- package/dist/noop-logger.d.ts.map +1 -0
- package/dist/noop-logger.js +8 -0
- package/dist/noop-logger.js.map +1 -0
- package/dist/prompt-diagnostics.d.ts +47 -0
- package/dist/prompt-diagnostics.d.ts.map +1 -0
- package/dist/prompt-diagnostics.js +230 -0
- package/dist/prompt-diagnostics.js.map +1 -0
- package/dist/provider-manager.d.ts +224 -0
- package/dist/provider-manager.d.ts.map +1 -0
- package/dist/provider-manager.js +563 -0
- package/dist/provider-manager.js.map +1 -0
- package/dist/provider-registry.d.ts +115 -0
- package/dist/provider-registry.d.ts.map +1 -0
- package/dist/provider-registry.js +305 -0
- package/dist/provider-registry.js.map +1 -0
- package/dist/schema-converter.d.ts +20 -0
- package/dist/schema-converter.d.ts.map +1 -0
- package/dist/schema-converter.js +48 -0
- package/dist/schema-converter.js.map +1 -0
- package/dist/skill-preprocessor.d.ts +46 -0
- package/dist/skill-preprocessor.d.ts.map +1 -0
- package/dist/skill-preprocessor.js +237 -0
- package/dist/skill-preprocessor.js.map +1 -0
- package/dist/skill-registry.d.ts +107 -0
- package/dist/skill-registry.d.ts.map +1 -0
- package/dist/skill-registry.js +330 -0
- package/dist/skill-registry.js.map +1 -0
- package/dist/skill-tool.d.ts +54 -0
- package/dist/skill-tool.d.ts.map +1 -0
- package/dist/skill-tool.js +88 -0
- package/dist/skill-tool.js.map +1 -0
- package/dist/sub-agent-manager.d.ts +90 -0
- package/dist/sub-agent-manager.d.ts.map +1 -0
- package/dist/sub-agent-manager.js +192 -0
- package/dist/sub-agent-manager.js.map +1 -0
- package/dist/token-estimator.d.ts +23 -0
- package/dist/token-estimator.d.ts.map +1 -0
- package/dist/token-estimator.js +27 -0
- package/dist/token-estimator.js.map +1 -0
- package/dist/tool-contract.d.ts +68 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +35 -0
- package/dist/tool-contract.js.map +1 -0
- package/dist/tool-result-persistence.d.ts +89 -0
- package/dist/tool-result-persistence.d.ts.map +1 -0
- package/dist/tool-result-persistence.js +152 -0
- package/dist/tool-result-persistence.js.map +1 -0
- package/dist/tools/bash/index.d.ts +71 -0
- package/dist/tools/bash/index.d.ts.map +1 -0
- package/dist/tools/bash/index.js +485 -0
- package/dist/tools/bash/index.js.map +1 -0
- package/dist/tools/bash/interactive.d.ts +47 -0
- package/dist/tools/bash/interactive.d.ts.map +1 -0
- package/dist/tools/bash/interactive.js +262 -0
- package/dist/tools/bash/interactive.js.map +1 -0
- package/dist/tools/bash/safety.d.ts +149 -0
- package/dist/tools/bash/safety.d.ts.map +1 -0
- package/dist/tools/bash/safety.js +1116 -0
- package/dist/tools/bash/safety.js.map +1 -0
- package/dist/tools/edit.d.ts +57 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +310 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/glob.d.ts +34 -0
- package/dist/tools/glob.d.ts.map +1 -0
- package/dist/tools/glob.js +268 -0
- package/dist/tools/glob.js.map +1 -0
- package/dist/tools/grep.d.ts +53 -0
- package/dist/tools/grep.d.ts.map +1 -0
- package/dist/tools/grep.js +673 -0
- package/dist/tools/grep.js.map +1 -0
- package/dist/tools/index.d.ts +62 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +52 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read.d.ts +43 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +459 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/runtime.d.ts +62 -0
- package/dist/tools/runtime.d.ts.map +1 -0
- package/dist/tools/runtime.js +116 -0
- package/dist/tools/runtime.js.map +1 -0
- package/dist/tools/shared/cwd-tracker.d.ts +32 -0
- package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
- package/dist/tools/shared/cwd-tracker.js +44 -0
- package/dist/tools/shared/cwd-tracker.js.map +1 -0
- package/dist/tools/shared/edit-history.d.ts +55 -0
- package/dist/tools/shared/edit-history.d.ts.map +1 -0
- package/dist/tools/shared/edit-history.js +72 -0
- package/dist/tools/shared/edit-history.js.map +1 -0
- package/dist/tools/shared/edit-matcher.d.ts +83 -0
- package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
- package/dist/tools/shared/edit-matcher.js +359 -0
- package/dist/tools/shared/edit-matcher.js.map +1 -0
- package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
- package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
- package/dist/tools/shared/file-mutation-lock.js +35 -0
- package/dist/tools/shared/file-mutation-lock.js.map +1 -0
- package/dist/tools/shared/gitignore.d.ts +17 -0
- package/dist/tools/shared/gitignore.d.ts.map +1 -0
- package/dist/tools/shared/gitignore.js +59 -0
- package/dist/tools/shared/gitignore.js.map +1 -0
- package/dist/tools/shared/pdf-extractor.d.ts +96 -0
- package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
- package/dist/tools/shared/pdf-extractor.js +196 -0
- package/dist/tools/shared/pdf-extractor.js.map +1 -0
- package/dist/tools/shared/read-registry.d.ts +66 -0
- package/dist/tools/shared/read-registry.d.ts.map +1 -0
- package/dist/tools/shared/read-registry.js +65 -0
- package/dist/tools/shared/read-registry.js.map +1 -0
- package/dist/tools/shared/safe-env.d.ts +18 -0
- package/dist/tools/shared/safe-env.d.ts.map +1 -0
- package/dist/tools/shared/safe-env.js +70 -0
- package/dist/tools/shared/safe-env.js.map +1 -0
- package/dist/tools/sub-agent.d.ts +91 -0
- package/dist/tools/sub-agent.d.ts.map +1 -0
- package/dist/tools/sub-agent.js +89 -0
- package/dist/tools/sub-agent.js.map +1 -0
- package/dist/tools/task-output.d.ts +38 -0
- package/dist/tools/task-output.d.ts.map +1 -0
- package/dist/tools/task-output.js +186 -0
- package/dist/tools/task-output.js.map +1 -0
- package/dist/tools/tool-search/index.d.ts +40 -0
- package/dist/tools/tool-search/index.d.ts.map +1 -0
- package/dist/tools/tool-search/index.js +110 -0
- package/dist/tools/tool-search/index.js.map +1 -0
- package/dist/tools/tool-search/registry.d.ts +82 -0
- package/dist/tools/tool-search/registry.d.ts.map +1 -0
- package/dist/tools/tool-search/registry.js +238 -0
- package/dist/tools/tool-search/registry.js.map +1 -0
- package/dist/tools/undo-edit.d.ts +51 -0
- package/dist/tools/undo-edit.d.ts.map +1 -0
- package/dist/tools/undo-edit.js +231 -0
- package/dist/tools/undo-edit.js.map +1 -0
- package/dist/tools/web-fetch/cache.d.ts +49 -0
- package/dist/tools/web-fetch/cache.d.ts.map +1 -0
- package/dist/tools/web-fetch/cache.js +89 -0
- package/dist/tools/web-fetch/cache.js.map +1 -0
- package/dist/tools/web-fetch/index.d.ts +53 -0
- package/dist/tools/web-fetch/index.d.ts.map +1 -0
- package/dist/tools/web-fetch/index.js +513 -0
- package/dist/tools/web-fetch/index.js.map +1 -0
- package/dist/tools/write.d.ts +59 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +316 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/types.d.ts +881 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/working-tags.d.ts +44 -0
- package/dist/working-tags.d.ts.map +1 -0
- package/dist/working-tags.js +103 -0
- package/dist/working-tags.js.map +1 -0
- package/package.json +87 -0
- package/src/budget-guard.ts +170 -0
- package/src/compaction/compaction.ts +386 -0
- package/src/compaction/failsafe.ts +185 -0
- package/src/compaction/index.ts +1199 -0
- package/src/compaction/microcompaction.ts +709 -0
- package/src/compaction/observational/buffering.ts +430 -0
- package/src/compaction/observational/constants.ts +532 -0
- package/src/compaction/observational/index.ts +837 -0
- package/src/compaction/observational/observer.ts +510 -0
- package/src/compaction/observational/recall-tool.ts +130 -0
- package/src/compaction/observational/reflector.ts +221 -0
- package/src/compaction/observational/types.ts +343 -0
- package/src/context-manager.ts +237 -0
- package/src/cortex-agent.ts +4297 -0
- package/src/error-classifier.ts +199 -0
- package/src/event-bridge.ts +508 -0
- package/src/index.ts +292 -0
- package/src/mcp-client.ts +582 -0
- package/src/model-wrapper.ts +128 -0
- package/src/noop-logger.ts +9 -0
- package/src/prompt-diagnostics.ts +296 -0
- package/src/provider-manager.ts +823 -0
- package/src/provider-registry.ts +386 -0
- package/src/schema-converter.ts +51 -0
- package/src/skill-preprocessor.ts +314 -0
- package/src/skill-registry.ts +378 -0
- package/src/skill-tool.ts +130 -0
- package/src/sub-agent-manager.ts +236 -0
- package/src/token-estimator.ts +26 -0
- package/src/tool-contract.ts +113 -0
- package/src/tool-result-persistence.ts +197 -0
- package/src/tools/bash/index.ts +633 -0
- package/src/tools/bash/interactive.ts +302 -0
- package/src/tools/bash/safety.ts +1297 -0
- package/src/tools/edit.ts +422 -0
- package/src/tools/glob.ts +330 -0
- package/src/tools/grep.ts +819 -0
- package/src/tools/index.ts +110 -0
- package/src/tools/read.ts +580 -0
- package/src/tools/runtime.ts +173 -0
- package/src/tools/shared/cwd-tracker.ts +50 -0
- package/src/tools/shared/edit-history.ts +96 -0
- package/src/tools/shared/edit-matcher.ts +457 -0
- package/src/tools/shared/file-mutation-lock.ts +40 -0
- package/src/tools/shared/gitignore.ts +61 -0
- package/src/tools/shared/pdf-extractor.ts +290 -0
- package/src/tools/shared/read-registry.ts +93 -0
- package/src/tools/shared/safe-env.ts +82 -0
- package/src/tools/sub-agent.ts +171 -0
- package/src/tools/task-output.ts +236 -0
- package/src/tools/tool-search/index.ts +167 -0
- package/src/tools/tool-search/registry.ts +278 -0
- package/src/tools/undo-edit.ts +314 -0
- package/src/tools/web-fetch/cache.ts +112 -0
- package/src/tools/web-fetch/index.ts +604 -0
- package/src/tools/write.ts +385 -0
- package/src/types.ts +1057 -0
- package/src/working-tags.ts +118 -0
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash tool safety layers.
|
|
3
|
+
*
|
|
4
|
+
* Seven layers of defense-in-depth for shell command execution, plus a
|
|
5
|
+
* final UX gate:
|
|
6
|
+
* 1. Environment variable stripping
|
|
7
|
+
* 2. Critical path protection
|
|
8
|
+
* 3. Command classification
|
|
9
|
+
* 4. Path validation for write commands
|
|
10
|
+
* 5. Obfuscation and injection detection
|
|
11
|
+
* 6. Script preflight
|
|
12
|
+
* 6.5. Interactive command detection (UX gate — prevents silent timeouts
|
|
13
|
+
* on editors, pagers, REPLs, and interactive DB clients)
|
|
14
|
+
* 7. Auto-mode classifier (utility model LLM call)
|
|
15
|
+
*
|
|
16
|
+
* Reference: docs/cortex/tools/bash.md (Safety Architecture)
|
|
17
|
+
*/
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import { buildSafeEnv as buildSafeEnvShared } from '../shared/safe-env.js';
|
|
21
|
+
import { checkInteractive } from './interactive.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Layer 1: Environment Variable Security
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Build a safe environment for child processes by stripping dangerous variables.
|
|
27
|
+
* Adds CORTEX_SHELL=exec as a context marker.
|
|
28
|
+
*
|
|
29
|
+
* Delegates to the shared buildSafeEnv utility so that both the Bash tool
|
|
30
|
+
* and the MCP client use the same blocklist.
|
|
31
|
+
*
|
|
32
|
+
* @param parentEnv - The source environment (typically process.env)
|
|
33
|
+
* @param overrides - Optional env var overrides that bypass the blocklist
|
|
34
|
+
*/
|
|
35
|
+
export function buildSafeEnv(parentEnv, overrides) {
|
|
36
|
+
return buildSafeEnvShared(parentEnv, 'exec', overrides);
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Layer 2: Critical Path Protection
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
const UNIX_CRITICAL_PATHS = [
|
|
42
|
+
'/',
|
|
43
|
+
'/usr',
|
|
44
|
+
'/etc',
|
|
45
|
+
'/boot',
|
|
46
|
+
'/sbin',
|
|
47
|
+
'/var',
|
|
48
|
+
'/System',
|
|
49
|
+
'/proc',
|
|
50
|
+
'/sys',
|
|
51
|
+
];
|
|
52
|
+
const MACOS_CRITICAL_PATHS = [
|
|
53
|
+
path.join(process.env['HOME'] ?? '', 'Library'),
|
|
54
|
+
];
|
|
55
|
+
const WINDOWS_CRITICAL_PATHS = [
|
|
56
|
+
'C:\\Windows',
|
|
57
|
+
'C:\\Windows\\System32',
|
|
58
|
+
'C:\\Program Files',
|
|
59
|
+
'C:\\Program Files (x86)',
|
|
60
|
+
'C:\\ProgramData',
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* Check if a target path resolves to a critical system directory.
|
|
64
|
+
*/
|
|
65
|
+
export function isCriticalPath(targetPath) {
|
|
66
|
+
const resolved = path.resolve(targetPath);
|
|
67
|
+
const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
68
|
+
const criticalPaths = process.platform === 'win32'
|
|
69
|
+
? WINDOWS_CRITICAL_PATHS
|
|
70
|
+
: [...UNIX_CRITICAL_PATHS, ...(process.platform === 'darwin' ? MACOS_CRITICAL_PATHS : [])];
|
|
71
|
+
for (const cp of criticalPaths) {
|
|
72
|
+
const normalizedCp = cp.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
73
|
+
if (normalized === normalizedCp || normalized.toLowerCase() === normalizedCp.toLowerCase()) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Check for Windows AppData
|
|
78
|
+
if (process.platform === 'win32') {
|
|
79
|
+
const userProfile = process.env['USERPROFILE'];
|
|
80
|
+
if (userProfile) {
|
|
81
|
+
const appDataPath = path.join(userProfile, 'AppData').replace(/\\/g, '/');
|
|
82
|
+
if (normalized.toLowerCase().startsWith(appDataPath.toLowerCase())) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Layer 3: Command Classification
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
const UNIX_READ_COMMANDS = new Set([
|
|
93
|
+
'cd', 'ls', 'find', 'cat', 'head', 'tail', 'sort', 'wc', 'diff',
|
|
94
|
+
'grep', 'echo', 'pwd', 'env', 'which', 'file', 'stat', 'strings',
|
|
95
|
+
'hexdump', 'less', 'more', 'tree',
|
|
96
|
+
]);
|
|
97
|
+
const UNIX_WRITE_COMMANDS = new Set([
|
|
98
|
+
'rm', 'rmdir', 'mv', 'cp', 'chmod', 'chown',
|
|
99
|
+
]);
|
|
100
|
+
const UNIX_CREATE_COMMANDS = new Set([
|
|
101
|
+
'mkdir', 'touch', 'tee',
|
|
102
|
+
]);
|
|
103
|
+
const UNIX_NETWORK_COMMANDS = new Set([
|
|
104
|
+
'curl', 'wget', 'ssh', 'scp', 'rsync', 'nc', 'nmap',
|
|
105
|
+
]);
|
|
106
|
+
const UNIX_SAFE_STDIN_COMMANDS = new Set([
|
|
107
|
+
'jq', 'cut', 'uniq', 'head', 'tail', 'tr', 'wc',
|
|
108
|
+
]);
|
|
109
|
+
const PS_READ_COMMANDS = new Set([
|
|
110
|
+
'get-content', 'get-childitem', 'get-item', 'get-location',
|
|
111
|
+
'select-string', 'compare-object', 'test-path', 'get-process',
|
|
112
|
+
'dir', 'type', 'where',
|
|
113
|
+
]);
|
|
114
|
+
const PS_WRITE_COMMANDS = new Set([
|
|
115
|
+
'remove-item', 'move-item', 'copy-item', 'set-content',
|
|
116
|
+
'rename-item', 'set-itemproperty',
|
|
117
|
+
]);
|
|
118
|
+
const PS_CREATE_COMMANDS = new Set([
|
|
119
|
+
'new-item', 'out-file', 'add-content',
|
|
120
|
+
]);
|
|
121
|
+
const PS_NETWORK_COMMANDS = new Set([
|
|
122
|
+
'invoke-webrequest', 'invoke-restmethod', 'test-netconnection', 'ssh',
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* Git subcommands that are read-only.
|
|
126
|
+
*/
|
|
127
|
+
const GIT_READ_SUBCOMMANDS = new Set([
|
|
128
|
+
'status', 'log', 'diff', 'show', 'branch', 'tag', 'remote', 'stash',
|
|
129
|
+
'blame', 'shortlog', 'describe', 'rev-parse', 'ls-files', 'ls-tree',
|
|
130
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* Safe-stdin denied flags per binary.
|
|
133
|
+
*/
|
|
134
|
+
const SAFE_STDIN_DENIED_FLAGS = {
|
|
135
|
+
grep: new Set(['-r', '-R', '-d', '-f', '--recursive', '--dereference-recursive', '--directories', '--file', '--exclude-from']),
|
|
136
|
+
jq: new Set(['-f', '-L', '--from-file', '--library-path', '--argfile', '--rawfile', '--slurpfile']),
|
|
137
|
+
sort: new Set(['-o', '-T', '--output', '--temporary-directory', '--compress-program', '--files0-from', '--random-source']),
|
|
138
|
+
wc: new Set(['--files0-from']),
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Split a command string on shell operators (; && || |) while respecting
|
|
142
|
+
* quoted strings. Returns the individual sub-commands.
|
|
143
|
+
*/
|
|
144
|
+
export function splitOnShellOperators(command) {
|
|
145
|
+
const subCommands = [];
|
|
146
|
+
let current = '';
|
|
147
|
+
let inSingle = false;
|
|
148
|
+
let inDouble = false;
|
|
149
|
+
let escaped = false;
|
|
150
|
+
let i = 0;
|
|
151
|
+
while (i < command.length) {
|
|
152
|
+
const ch = command[i];
|
|
153
|
+
if (escaped) {
|
|
154
|
+
current += ch;
|
|
155
|
+
escaped = false;
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (ch === '\\') {
|
|
160
|
+
escaped = true;
|
|
161
|
+
current += ch;
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ch === "'" && !inDouble) {
|
|
166
|
+
inSingle = !inSingle;
|
|
167
|
+
current += ch;
|
|
168
|
+
i++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === '"' && !inSingle) {
|
|
172
|
+
inDouble = !inDouble;
|
|
173
|
+
current += ch;
|
|
174
|
+
i++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Only split when outside quotes
|
|
178
|
+
if (!inSingle && !inDouble) {
|
|
179
|
+
// Check for && or ||
|
|
180
|
+
if ((ch === '&' && command[i + 1] === '&') || (ch === '|' && command[i + 1] === '|')) {
|
|
181
|
+
if (current.trim())
|
|
182
|
+
subCommands.push(current.trim());
|
|
183
|
+
current = '';
|
|
184
|
+
i += 2;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// Check for single pipe (not ||) or semicolon
|
|
188
|
+
if (ch === ';' || (ch === '|' && command[i + 1] !== '|')) {
|
|
189
|
+
if (current.trim())
|
|
190
|
+
subCommands.push(current.trim());
|
|
191
|
+
current = '';
|
|
192
|
+
i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
current += ch;
|
|
197
|
+
i++;
|
|
198
|
+
}
|
|
199
|
+
if (current.trim())
|
|
200
|
+
subCommands.push(current.trim());
|
|
201
|
+
return subCommands;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Extract the command name from a single (non-compound) command string.
|
|
205
|
+
*/
|
|
206
|
+
function extractCommandName(singleCommand) {
|
|
207
|
+
const trimmed = singleCommand.trim();
|
|
208
|
+
// Handle 'sed -i' specifically
|
|
209
|
+
if (/^sed\s+.*-i/.test(trimmed))
|
|
210
|
+
return 'sed-i';
|
|
211
|
+
// Get the first token (the command name)
|
|
212
|
+
const tokens = trimmed.split(/\s+/);
|
|
213
|
+
return (tokens[0] ?? '').toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Risk ordering from lowest to highest. Used to pick the most dangerous
|
|
217
|
+
* classification when a compound command contains multiple sub-commands.
|
|
218
|
+
*/
|
|
219
|
+
const CLASSIFICATION_RISK_ORDER = [
|
|
220
|
+
'read',
|
|
221
|
+
'safe-stdin',
|
|
222
|
+
'create',
|
|
223
|
+
'write',
|
|
224
|
+
'network',
|
|
225
|
+
'unknown',
|
|
226
|
+
];
|
|
227
|
+
/**
|
|
228
|
+
* Return the higher-risk classification of two values.
|
|
229
|
+
*/
|
|
230
|
+
function higherRisk(a, b) {
|
|
231
|
+
const aIdx = CLASSIFICATION_RISK_ORDER.indexOf(a);
|
|
232
|
+
const bIdx = CLASSIFICATION_RISK_ORDER.indexOf(b);
|
|
233
|
+
return aIdx >= bIdx ? a : b;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Classify a single (non-compound) command by its potential impact.
|
|
237
|
+
*/
|
|
238
|
+
function classifySingleCommand(singleCommand) {
|
|
239
|
+
const cmdName = extractCommandName(singleCommand);
|
|
240
|
+
const isWindows = process.platform === 'win32';
|
|
241
|
+
if (isWindows) {
|
|
242
|
+
const psCmd = cmdName.toLowerCase();
|
|
243
|
+
if (PS_READ_COMMANDS.has(psCmd))
|
|
244
|
+
return 'read';
|
|
245
|
+
if (PS_WRITE_COMMANDS.has(psCmd))
|
|
246
|
+
return 'write';
|
|
247
|
+
if (PS_CREATE_COMMANDS.has(psCmd))
|
|
248
|
+
return 'create';
|
|
249
|
+
if (PS_NETWORK_COMMANDS.has(psCmd))
|
|
250
|
+
return 'network';
|
|
251
|
+
// Handle PS aliases
|
|
252
|
+
if (psCmd === 'curl' || psCmd === 'wget')
|
|
253
|
+
return 'network';
|
|
254
|
+
return 'unknown';
|
|
255
|
+
}
|
|
256
|
+
// Unix
|
|
257
|
+
// Handle git subcommands
|
|
258
|
+
if (cmdName === 'git') {
|
|
259
|
+
const parts = singleCommand.trim().split(/\s+/);
|
|
260
|
+
const subcommand = parts[1]?.toLowerCase();
|
|
261
|
+
if (subcommand && GIT_READ_SUBCOMMANDS.has(subcommand))
|
|
262
|
+
return 'read';
|
|
263
|
+
return 'unknown';
|
|
264
|
+
}
|
|
265
|
+
// Handle sed -i (write)
|
|
266
|
+
if (cmdName === 'sed-i')
|
|
267
|
+
return 'write';
|
|
268
|
+
if (UNIX_READ_COMMANDS.has(cmdName))
|
|
269
|
+
return 'read';
|
|
270
|
+
if (UNIX_WRITE_COMMANDS.has(cmdName))
|
|
271
|
+
return 'write';
|
|
272
|
+
if (UNIX_CREATE_COMMANDS.has(cmdName))
|
|
273
|
+
return 'create';
|
|
274
|
+
if (UNIX_NETWORK_COMMANDS.has(cmdName))
|
|
275
|
+
return 'network';
|
|
276
|
+
// Check safe-stdin
|
|
277
|
+
if (UNIX_SAFE_STDIN_COMMANDS.has(cmdName)) {
|
|
278
|
+
// Verify no denied flags and no file args
|
|
279
|
+
const tokens = singleCommand.trim().split(/\s+/);
|
|
280
|
+
const deniedFlags = SAFE_STDIN_DENIED_FLAGS[cmdName];
|
|
281
|
+
if (deniedFlags) {
|
|
282
|
+
for (const token of tokens.slice(1)) {
|
|
283
|
+
if (deniedFlags.has(token))
|
|
284
|
+
return 'unknown';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Check for path-like positional arguments (simple heuristic)
|
|
288
|
+
const args = tokens.slice(1).filter((t) => !t.startsWith('-'));
|
|
289
|
+
const hasPathArgs = args.some((a) => a.includes('/') || a.includes('.'));
|
|
290
|
+
if (hasPathArgs)
|
|
291
|
+
return 'unknown';
|
|
292
|
+
return 'safe-stdin';
|
|
293
|
+
}
|
|
294
|
+
return 'unknown';
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Classify a command (potentially compound) by its potential impact.
|
|
298
|
+
* For compound commands, returns the highest-risk classification
|
|
299
|
+
* among all sub-commands.
|
|
300
|
+
*/
|
|
301
|
+
export function classifyCommand(command) {
|
|
302
|
+
const subCommands = splitOnShellOperators(command);
|
|
303
|
+
if (subCommands.length === 0)
|
|
304
|
+
return 'unknown';
|
|
305
|
+
let result = classifySingleCommand(subCommands[0]);
|
|
306
|
+
for (let i = 1; i < subCommands.length; i++) {
|
|
307
|
+
result = higherRisk(result, classifySingleCommand(subCommands[i]));
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Layer 4: Path Validation
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
/**
|
|
315
|
+
* Extract target paths from write/create commands in a single sub-command.
|
|
316
|
+
*/
|
|
317
|
+
function extractWritePathsFromSingle(singleCommand) {
|
|
318
|
+
const paths = [];
|
|
319
|
+
const tokens = singleCommand.trim().split(/\s+/);
|
|
320
|
+
const cmd = (tokens[0] ?? '').toLowerCase();
|
|
321
|
+
if (['rm', 'rmdir', 'mv', 'cp', 'touch', 'mkdir'].includes(cmd)) {
|
|
322
|
+
// Last argument(s) that aren't flags
|
|
323
|
+
for (let i = tokens.length - 1; i > 0; i--) {
|
|
324
|
+
const token = tokens[i];
|
|
325
|
+
if (!token.startsWith('-')) {
|
|
326
|
+
paths.push(token);
|
|
327
|
+
// For rm, rmdir, touch, mkdir - all non-flag args are targets
|
|
328
|
+
// For mv, cp - last arg is destination
|
|
329
|
+
if (['mv', 'cp'].includes(cmd))
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return paths;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Extract target paths from write/create commands.
|
|
338
|
+
* Returns the paths that would be modified by the command.
|
|
339
|
+
* Handles compound commands by extracting paths from all sub-commands.
|
|
340
|
+
*/
|
|
341
|
+
export function extractWritePaths(command) {
|
|
342
|
+
const subCommands = splitOnShellOperators(command);
|
|
343
|
+
const paths = [];
|
|
344
|
+
for (const sub of subCommands) {
|
|
345
|
+
paths.push(...extractWritePathsFromSingle(sub));
|
|
346
|
+
}
|
|
347
|
+
return paths;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Resolve a path, following symlinks when the target exists.
|
|
351
|
+
* Falls back to path.resolve() if the path does not yet exist.
|
|
352
|
+
*/
|
|
353
|
+
function resolveWithSymlinks(targetPath) {
|
|
354
|
+
try {
|
|
355
|
+
return fs.realpathSync(targetPath);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// Path does not exist yet (e.g., mkdir for a new directory), fall back
|
|
359
|
+
return path.resolve(targetPath);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Validate that write paths are within the allowed working directory.
|
|
364
|
+
*/
|
|
365
|
+
export function validateWritePaths(command, workingDirectory, currentCwd) {
|
|
366
|
+
const classification = classifyCommand(command);
|
|
367
|
+
if (classification !== 'write' && classification !== 'create') {
|
|
368
|
+
return { allowed: true, classification };
|
|
369
|
+
}
|
|
370
|
+
const writePaths = extractWritePaths(command);
|
|
371
|
+
for (const wp of writePaths) {
|
|
372
|
+
// Resolve relative to current CWD, then resolve symlinks
|
|
373
|
+
const rawResolved = path.resolve(currentCwd, wp);
|
|
374
|
+
const resolved = resolveWithSymlinks(rawResolved);
|
|
375
|
+
// Check critical paths
|
|
376
|
+
if (isCriticalPath(resolved)) {
|
|
377
|
+
return {
|
|
378
|
+
allowed: false,
|
|
379
|
+
reason: 'This command would modify a critical system directory. This cannot be auto-allowed.',
|
|
380
|
+
classification,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { allowed: true, classification };
|
|
385
|
+
}
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Layer 5: Obfuscation and Injection Detection
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
/**
|
|
390
|
+
* Strip invisible Unicode characters that could be used for obfuscation.
|
|
391
|
+
*/
|
|
392
|
+
export function stripInvisibleChars(command) {
|
|
393
|
+
// Zero-width characters, BiDi markers, variation selectors, tag characters
|
|
394
|
+
return command.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD\u034F\u061C\u180E\u2060-\u2069\uFFF9-\uFFFB\u{E0001}-\u{E007F}\u{FE00}-\u{FE0F}]/gu, '');
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Safe URL allowlist for download-and-execute patterns.
|
|
398
|
+
*/
|
|
399
|
+
const SAFE_DOWNLOAD_URLS = [
|
|
400
|
+
{ host: 'brew.sh' },
|
|
401
|
+
{ host: 'get.pnpm.io' },
|
|
402
|
+
{ host: 'bun.sh', pathPrefix: '/install' },
|
|
403
|
+
{ host: 'sh.rustup.rs' },
|
|
404
|
+
{ host: 'get.docker.com' },
|
|
405
|
+
{ host: 'install.python-poetry.org' },
|
|
406
|
+
{ host: 'raw.githubusercontent.com', pathPrefix: '/Homebrew/' },
|
|
407
|
+
{ host: 'raw.githubusercontent.com', pathPrefix: '/nvm-sh/nvm/' },
|
|
408
|
+
];
|
|
409
|
+
/**
|
|
410
|
+
* Check if a URL is in the safe download allowlist.
|
|
411
|
+
*/
|
|
412
|
+
function isSafeDownloadUrl(url) {
|
|
413
|
+
try {
|
|
414
|
+
const parsed = new URL(url);
|
|
415
|
+
// Reject URLs with credentials
|
|
416
|
+
if (parsed.username || parsed.password)
|
|
417
|
+
return false;
|
|
418
|
+
const host = parsed.hostname.toLowerCase();
|
|
419
|
+
const pathname = parsed.pathname;
|
|
420
|
+
for (const entry of SAFE_DOWNLOAD_URLS) {
|
|
421
|
+
if (host === entry.host || host === `www.${entry.host}`) {
|
|
422
|
+
if (!entry.pathPrefix || pathname.startsWith(entry.pathPrefix)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Invalid URL
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Extract URLs from a command string.
|
|
435
|
+
*/
|
|
436
|
+
function extractUrls(command) {
|
|
437
|
+
const urlRegex = /https?:\/\/[^\s'"]+/g;
|
|
438
|
+
return command.match(urlRegex) ?? [];
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Unix obfuscation and injection patterns.
|
|
442
|
+
*/
|
|
443
|
+
const UNIX_OBFUSCATION_PATTERNS = [
|
|
444
|
+
// Encoded execution
|
|
445
|
+
{ pattern: /base64\s+(-d|--decode)\s*\|.*\b(ba)?sh\b/i, description: 'Base64 decode piped to shell' },
|
|
446
|
+
{ pattern: /xxd\s+-r\s*\|.*\b(ba)?sh\b/i, description: 'Hex decode piped to shell' },
|
|
447
|
+
{ pattern: /printf\s+.*\\x.*\|.*\b(ba)?sh\b/i, description: 'Printf escape sequences piped to shell' },
|
|
448
|
+
// Eval injection
|
|
449
|
+
{ pattern: /\beval\s+.*(\$\(|`|base64|\\x|\\[0-7])/i, description: 'Eval with encoded/obfuscated input' },
|
|
450
|
+
// Heredoc execution
|
|
451
|
+
{ pattern: /<<\s*['"]?\w+['"]?\s*\n.*\b(ba)?sh\b/is, description: 'Heredoc used to construct and execute commands' },
|
|
452
|
+
// Escape sequences
|
|
453
|
+
{ pattern: /\$'\\[0-7]{3}.*\\[0-7]{3}'/, description: 'Bash octal escape sequences constructing commands' },
|
|
454
|
+
{ pattern: /\$'\\x[0-9a-f]{2}.*\\x[0-9a-f]{2}'/i, description: 'Bash hex escape sequences constructing commands' },
|
|
455
|
+
// Polyglot injection
|
|
456
|
+
{ pattern: /python[23]?\s+-c\s+.*(?:base64|eval|exec|__import__)/i, description: 'Python with obfuscation patterns' },
|
|
457
|
+
{ pattern: /perl\s+-e\s+.*(?:eval|unpack|decode_base64)/i, description: 'Perl with obfuscation patterns' },
|
|
458
|
+
{ pattern: /ruby\s+-e\s+.*(?:eval|Base64|decode64)/i, description: 'Ruby with obfuscation patterns' },
|
|
459
|
+
// Variable obfuscation
|
|
460
|
+
{ pattern: /\w+=[^;]*;\s*\w+=[^;]*;\s*\$\{?\w+\}?\$\{?\w+\}?/i, description: 'Variable assignment chains constructing commands' },
|
|
461
|
+
// Process substitution with remote content
|
|
462
|
+
{ pattern: /<\(.*(?:curl|wget|nc)\s+/i, description: 'Remote content via process substitution' },
|
|
463
|
+
// Shell metacharacters — uses quote-aware matching in checkObfuscation()
|
|
464
|
+
// so that legitimate regex patterns inside quotes (e.g., grep "foo\|bar") are not flagged.
|
|
465
|
+
{ pattern: /\\[;&|]/, description: 'Backslash-escaped operators or whitespace', quoteAware: true },
|
|
466
|
+
{ pattern: /[\u200B\u200C\u200D\uFEFF\u00A0]/, description: 'Unicode whitespace characters' },
|
|
467
|
+
{ pattern: /[\x00-\x08\x0E-\x1F]/, description: 'Control characters in command' },
|
|
468
|
+
{ pattern: /\w#\w/, description: 'Mid-word hash (potential comment injection)' },
|
|
469
|
+
{ pattern: /['"]-+\w/, description: 'Obfuscated flags via quotes' },
|
|
470
|
+
// Structural
|
|
471
|
+
{ pattern: /#.*['"].*\n/, description: 'Comment/quote desync pattern' },
|
|
472
|
+
{ pattern: /'[^']*\n[^']*'/, description: 'Embedded newlines in single-quoted strings' },
|
|
473
|
+
{ pattern: /[|;&]\s*$/, description: 'Incomplete command (trailing pipe or semicolon)' },
|
|
474
|
+
// NOTE: IFS manipulation and /proc access are handled by dedicated
|
|
475
|
+
// quote-aware validators below (checkIfsInjection, checkProcSysAccess).
|
|
476
|
+
];
|
|
477
|
+
/**
|
|
478
|
+
* PowerShell obfuscation patterns.
|
|
479
|
+
*/
|
|
480
|
+
const PS_OBFUSCATION_PATTERNS = [
|
|
481
|
+
{ pattern: /-EncodedCommand\b/i, description: 'PowerShell encoded command' },
|
|
482
|
+
{ pattern: /\[Convert\]::FromBase64String.*\|\s*iex/i, description: 'Base64 decode piped to Invoke-Expression' },
|
|
483
|
+
{ pattern: /Invoke-Expression\s+.*(\+|\[char\]|\.Replace)/i, description: 'Invoke-Expression with constructed strings' },
|
|
484
|
+
{ pattern: /Net\.WebClient.*DownloadString.*\|\s*iex/i, description: 'Download cradle piped to iex' },
|
|
485
|
+
{ pattern: /Invoke-WebRequest.*\|\s*iex/i, description: 'Web request piped to Invoke-Expression' },
|
|
486
|
+
{ pattern: /Start-Process.*-WindowStyle\s+Hidden/i, description: 'Hidden process execution' },
|
|
487
|
+
{ pattern: /\[Reflection\.Assembly\]::Load/i, description: 'Reflection-based assembly loading' },
|
|
488
|
+
{ pattern: /-ExecutionPolicy\s+Bypass/i, description: 'Execution policy bypass' },
|
|
489
|
+
];
|
|
490
|
+
/**
|
|
491
|
+
* Strip the content of single-quoted and double-quoted strings from a command,
|
|
492
|
+
* preserving the quotes themselves. This lets obfuscation patterns check only
|
|
493
|
+
* the unquoted portions of a command so that legitimate regex syntax inside
|
|
494
|
+
* quotes (e.g., grep "foo\|bar") is not flagged as shell obfuscation.
|
|
495
|
+
*/
|
|
496
|
+
function stripQuotedContent(command) {
|
|
497
|
+
return command.replace(/"[^"]*"|'[^']*'/g, (match) => {
|
|
498
|
+
// Preserve the quote characters but empty the content
|
|
499
|
+
const quote = match[0];
|
|
500
|
+
return `${quote}${quote}`;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Analyze the quoting context of each character in a shell command.
|
|
505
|
+
* Returns an array of QuoteContext values, one per character, indicating
|
|
506
|
+
* whether that position is inside single quotes, double quotes, backticks,
|
|
507
|
+
* escaped, or unquoted. Handles nested escapes correctly (e.g., `\"` inside
|
|
508
|
+
* double quotes keeps the next character as "double", not "escaped").
|
|
509
|
+
*/
|
|
510
|
+
export function analyzeQuoteState(command) {
|
|
511
|
+
const states = new Array(command.length);
|
|
512
|
+
let context = 'none';
|
|
513
|
+
// Track the context to return to when a backtick closes (for backtick-in-double-quote)
|
|
514
|
+
let returnContext = 'none';
|
|
515
|
+
for (let i = 0; i < command.length; i++) {
|
|
516
|
+
const ch = command[i];
|
|
517
|
+
if (context === 'single') {
|
|
518
|
+
// Inside single quotes, only a closing single quote ends the context.
|
|
519
|
+
// No escape processing at all inside single quotes.
|
|
520
|
+
if (ch === "'") {
|
|
521
|
+
states[i] = 'none';
|
|
522
|
+
context = 'none';
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
states[i] = 'single';
|
|
526
|
+
}
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (context === 'double') {
|
|
530
|
+
// Inside double quotes, backslash only escapes: $, `, ", \, and newline.
|
|
531
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
532
|
+
const next = command[i + 1];
|
|
533
|
+
if ('$`"\\'.includes(next) || next === '\n') {
|
|
534
|
+
states[i] = 'escaped';
|
|
535
|
+
states[i + 1] = 'escaped';
|
|
536
|
+
i++; // skip the escaped character
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (ch === '"') {
|
|
541
|
+
states[i] = 'none';
|
|
542
|
+
context = 'none';
|
|
543
|
+
}
|
|
544
|
+
else if (ch === '`') {
|
|
545
|
+
// Backticks nest inside double quotes. Track return context so we
|
|
546
|
+
// resume double-quote context when the backtick closes.
|
|
547
|
+
states[i] = 'backtick';
|
|
548
|
+
returnContext = 'double';
|
|
549
|
+
context = 'backtick';
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
states[i] = 'double';
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (context === 'backtick') {
|
|
557
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
558
|
+
states[i] = 'escaped';
|
|
559
|
+
states[i + 1] = 'escaped';
|
|
560
|
+
i++;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (ch === '`') {
|
|
564
|
+
states[i] = returnContext === 'double' ? 'double' : 'none';
|
|
565
|
+
context = returnContext;
|
|
566
|
+
returnContext = 'none';
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
states[i] = 'backtick';
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
// context === 'none' (unquoted)
|
|
574
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
575
|
+
states[i] = 'escaped';
|
|
576
|
+
states[i + 1] = 'escaped';
|
|
577
|
+
i++;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (ch === "'") {
|
|
581
|
+
states[i] = 'single'; // the quote character itself is "in" single-quote context
|
|
582
|
+
context = 'single';
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (ch === '"') {
|
|
586
|
+
states[i] = 'double';
|
|
587
|
+
context = 'double';
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (ch === '`') {
|
|
591
|
+
states[i] = 'backtick';
|
|
592
|
+
context = 'backtick';
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
states[i] = 'none';
|
|
596
|
+
}
|
|
597
|
+
return states;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Extract the unquoted portions of a command using the quote state machine.
|
|
601
|
+
* Returns a string where quoted characters are replaced with spaces (preserving
|
|
602
|
+
* positions) so that regex matches on the result correspond to unquoted regions.
|
|
603
|
+
*/
|
|
604
|
+
function getUnquotedText(command, states) {
|
|
605
|
+
const chars = [];
|
|
606
|
+
for (let i = 0; i < command.length; i++) {
|
|
607
|
+
chars.push(states[i] === 'none' ? command[i] : ' ');
|
|
608
|
+
}
|
|
609
|
+
return chars.join('');
|
|
610
|
+
}
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// Validator 2: Enhanced IFS Injection
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
/**
|
|
615
|
+
* Detect IFS variable manipulation in unquoted context.
|
|
616
|
+
* `IFS=` inside quotes is harmless (just a string literal).
|
|
617
|
+
* Unquoted `IFS=` is a shell variable assignment that can enable attacks.
|
|
618
|
+
*/
|
|
619
|
+
export function checkIfsInjection(command, states) {
|
|
620
|
+
const unquoted = getUnquotedText(command, states);
|
|
621
|
+
if (/\bIFS\s*=/.test(unquoted)) {
|
|
622
|
+
return {
|
|
623
|
+
allowed: false,
|
|
624
|
+
reason: 'Obfuscation pattern detected: IFS variable manipulation',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return { allowed: true };
|
|
628
|
+
}
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Validator 3: Enhanced proc/sys Access
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
/**
|
|
633
|
+
* Sensitive paths under /proc and /sys that can be used for exfiltration
|
|
634
|
+
* or system introspection attacks.
|
|
635
|
+
*/
|
|
636
|
+
const PROC_SYS_PATTERNS = [
|
|
637
|
+
// /proc exfiltration vectors
|
|
638
|
+
/\/proc\/[^/]*\/environ/,
|
|
639
|
+
/\/proc\/[^/]*\/cmdline/,
|
|
640
|
+
/\/proc\/[^/]*\/maps/,
|
|
641
|
+
/\/proc\/[^/]*\/mem\b/,
|
|
642
|
+
/\/proc\/[^/]*\/fd\//,
|
|
643
|
+
/\/proc\/[^/]*\/exe\b/,
|
|
644
|
+
/\/proc\/[^/]*\/cwd\b/,
|
|
645
|
+
/\/proc\/[^/]*\/root\b/,
|
|
646
|
+
/\/proc\/[^/]*\/mountinfo/,
|
|
647
|
+
/\/proc\/[^/]*\/status/,
|
|
648
|
+
// /sys sensitive paths
|
|
649
|
+
/\/sys\/class\/net\b/,
|
|
650
|
+
/\/sys\/kernel\//,
|
|
651
|
+
/\/sys\/firmware\//,
|
|
652
|
+
/\/sys\/fs\/cgroup\//,
|
|
653
|
+
];
|
|
654
|
+
/**
|
|
655
|
+
* Detect access to sensitive /proc and /sys paths in unquoted context.
|
|
656
|
+
* Quoted references (e.g., `echo "/proc/self/environ"`) are harmless string
|
|
657
|
+
* literals. Unquoted references indicate actual filesystem access attempts.
|
|
658
|
+
*/
|
|
659
|
+
export function checkProcSysAccess(command, states) {
|
|
660
|
+
const unquoted = getUnquotedText(command, states);
|
|
661
|
+
for (const pattern of PROC_SYS_PATTERNS) {
|
|
662
|
+
if (pattern.test(unquoted)) {
|
|
663
|
+
return {
|
|
664
|
+
allowed: false,
|
|
665
|
+
reason: 'Obfuscation pattern detected: Access to sensitive /proc or /sys path',
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { allowed: true };
|
|
670
|
+
}
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
// Validator 4: jq system() Blocking
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
/**
|
|
675
|
+
* Detect jq command abuse: system() calls, @sh filter for shell injection,
|
|
676
|
+
* and -n with module imports that could load malicious jq modules.
|
|
677
|
+
*/
|
|
678
|
+
export function checkJqAbuse(command) {
|
|
679
|
+
// Only check commands that invoke jq
|
|
680
|
+
if (!/\bjq\b/.test(command)) {
|
|
681
|
+
return { allowed: true };
|
|
682
|
+
}
|
|
683
|
+
// Block jq filters containing system( -- executes shell commands from jq
|
|
684
|
+
// Use dotAll (s) flag so multi-line jq filters are caught
|
|
685
|
+
if (/\bjq\b.*\bsystem\s*\(/s.test(command)) {
|
|
686
|
+
return {
|
|
687
|
+
allowed: false,
|
|
688
|
+
reason: 'Obfuscation pattern detected: jq system() call can execute arbitrary shell commands',
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
// Block @sh filter used for shell injection
|
|
692
|
+
if (/\bjq\b.*@sh\b/s.test(command)) {
|
|
693
|
+
return {
|
|
694
|
+
allowed: false,
|
|
695
|
+
reason: 'Obfuscation pattern detected: jq @sh filter can be used for shell injection',
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
// Block jq -n with import/include (module loading)
|
|
699
|
+
if (/\bjq\b\s+.*-n\b.*\b(import|include)\b/s.test(command) || /\bjq\b\s+.*\b(import|include)\b.*-n\b/s.test(command)) {
|
|
700
|
+
return {
|
|
701
|
+
allowed: false,
|
|
702
|
+
reason: 'Obfuscation pattern detected: jq module import with -n flag',
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
return { allowed: true };
|
|
706
|
+
}
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// Validator 5: ANSI-C Quoting Detection
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
/**
|
|
711
|
+
* Detect ANSI-C quoting ($'...') with hex or octal escape sequences that
|
|
712
|
+
* encode potentially dangerous content. Simple escapes like $'\n' and $'\t'
|
|
713
|
+
* are legitimate and allowed.
|
|
714
|
+
*/
|
|
715
|
+
export function checkAnsiCQuoting(command) {
|
|
716
|
+
// Match $'...' patterns. We need to find all ANSI-C quoted strings and
|
|
717
|
+
// check if they contain hex (\xHH) or octal (\0NNN or \NNN with 3 digits) escapes.
|
|
718
|
+
const ansiCPattern = /\$'([^'\\]*(?:\\.[^'\\]*)*)'/g;
|
|
719
|
+
let match;
|
|
720
|
+
while ((match = ansiCPattern.exec(command)) !== null) {
|
|
721
|
+
const content = match[1] ?? '';
|
|
722
|
+
// Check for hex escapes (\xHH)
|
|
723
|
+
const hasHex = /\\x[0-9a-fA-F]{2}/.test(content);
|
|
724
|
+
// Check for octal escapes (\0NNN or \NNN where N are 3 octal digits)
|
|
725
|
+
const hasOctal = /\\0[0-7]{1,3}/.test(content) || /\\[1-3][0-7]{2}/.test(content);
|
|
726
|
+
if (hasHex || hasOctal) {
|
|
727
|
+
return {
|
|
728
|
+
allowed: false,
|
|
729
|
+
reason: 'Obfuscation pattern detected: ANSI-C quoting with hex/octal escapes can encode hidden commands',
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return { allowed: true };
|
|
734
|
+
}
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
// Validator 6: Enhanced Heredoc Validation
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
/**
|
|
739
|
+
* Detect heredoc patterns and validate their content. Unquoted heredoc
|
|
740
|
+
* delimiters (<<EOF) allow variable expansion and command substitution in
|
|
741
|
+
* the body, which can be used for injection. Quoted delimiters (<<'EOF')
|
|
742
|
+
* are treated as literal text and are safe.
|
|
743
|
+
*/
|
|
744
|
+
export function checkHeredocInjection(command) {
|
|
745
|
+
// Match heredoc operators: <<[-]?DELIMITER or <<[-]?"DELIMITER" or <<[-]?'DELIMITER'
|
|
746
|
+
// We look for the delimiter, then try to find the body if it is inline (multi-line command).
|
|
747
|
+
const heredocPattern = /<<-?\s*(["']?)(\w+)\1/g;
|
|
748
|
+
let match;
|
|
749
|
+
while ((match = heredocPattern.exec(command)) !== null) {
|
|
750
|
+
const quoteChar = match[1] ?? '';
|
|
751
|
+
const delimiter = match[2] ?? '';
|
|
752
|
+
const isQuoted = quoteChar !== '';
|
|
753
|
+
if (isQuoted || !delimiter) {
|
|
754
|
+
// Quoted heredocs are safe (no expansion), skip
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
// For unquoted heredocs, check if the body (text after the delimiter line
|
|
758
|
+
// and before the closing delimiter) contains injection patterns.
|
|
759
|
+
const afterMatch = command.substring(match.index + match[0].length);
|
|
760
|
+
// The body starts after a newline following the heredoc operator
|
|
761
|
+
const newlineIdx = afterMatch.indexOf('\n');
|
|
762
|
+
if (newlineIdx === -1)
|
|
763
|
+
continue; // no body present in the command string
|
|
764
|
+
const bodyAndRest = afterMatch.substring(newlineIdx + 1);
|
|
765
|
+
const closingPattern = new RegExp(`^${delimiter}\\s*$`, 'm');
|
|
766
|
+
const closingMatch = closingPattern.exec(bodyAndRest);
|
|
767
|
+
const body = closingMatch ? bodyAndRest.substring(0, closingMatch.index) : bodyAndRest;
|
|
768
|
+
// Check the heredoc body for injection patterns
|
|
769
|
+
const injectionPatterns = [
|
|
770
|
+
/\$\(/, // command substitution
|
|
771
|
+
/`[^`]+`/, // backtick command substitution
|
|
772
|
+
/\$\{.*[^}]*\}/, // parameter expansion with manipulation
|
|
773
|
+
];
|
|
774
|
+
for (const pattern of injectionPatterns) {
|
|
775
|
+
if (pattern.test(body)) {
|
|
776
|
+
return {
|
|
777
|
+
allowed: false,
|
|
778
|
+
reason: 'Obfuscation pattern detected: Unquoted heredoc with command substitution in body',
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return { allowed: true };
|
|
784
|
+
}
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// Validator 7: Brace Expansion Detection
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
/**
|
|
789
|
+
* Detect brace expansion patterns ({a,b} or {1..N}) in unquoted context
|
|
790
|
+
* that target suspicious paths or combine with dangerous commands.
|
|
791
|
+
*/
|
|
792
|
+
export function checkBraceExpansion(command, states) {
|
|
793
|
+
const unquoted = getUnquotedText(command, states);
|
|
794
|
+
// Find {x,y} patterns in unquoted text. Only flag when combined with
|
|
795
|
+
// destructive commands or when referencing sensitive system paths.
|
|
796
|
+
// Benign patterns like `diff {old,new}/config.ts` should pass.
|
|
797
|
+
const commaExpansion = /\{[^}]*,[^}]*\}/g;
|
|
798
|
+
let match;
|
|
799
|
+
// Extract leading command for context-aware decisions
|
|
800
|
+
const firstToken = unquoted.trim().split(/\s+/)[0] ?? '';
|
|
801
|
+
const destructiveCommands = ['rm', 'chmod', 'chown', 'mv', 'rmdir', 'dd', 'shred'];
|
|
802
|
+
while ((match = commaExpansion.exec(unquoted)) !== null) {
|
|
803
|
+
const content = match[0];
|
|
804
|
+
// Flag if expansion references sensitive paths
|
|
805
|
+
if (/\betc\b|passwd|shadow|authorized_keys|\bssh\b|\bproc\b|\bsys\b/.test(content)) {
|
|
806
|
+
return {
|
|
807
|
+
allowed: false,
|
|
808
|
+
reason: 'Obfuscation pattern detected: Brace expansion referencing sensitive paths',
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
// Flag if any element starts with absolute path AND command is destructive
|
|
812
|
+
if (/\{\/|,\s*\//.test(content) && destructiveCommands.includes(firstToken.toLowerCase())) {
|
|
813
|
+
return {
|
|
814
|
+
allowed: false,
|
|
815
|
+
reason: 'Obfuscation pattern detected: Brace expansion with absolute paths in destructive command',
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// Check for range expansion {N..M} combined with dangerous commands.
|
|
820
|
+
// Look at the first token of the overall command to determine context.
|
|
821
|
+
const rangeExpansion = /\{[^}]*\.\.[^}]*\}/g;
|
|
822
|
+
if (rangeExpansion.test(unquoted)) {
|
|
823
|
+
// Extract the leading command name from the unquoted text
|
|
824
|
+
const firstToken = unquoted.trim().split(/\s+/)[0] ?? '';
|
|
825
|
+
const destructiveCommands = ['rm', 'chmod', 'chown', 'mv', 'cp'];
|
|
826
|
+
if (destructiveCommands.includes(firstToken.toLowerCase())) {
|
|
827
|
+
return {
|
|
828
|
+
allowed: false,
|
|
829
|
+
reason: 'Obfuscation pattern detected: Brace range expansion with destructive command',
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return { allowed: true };
|
|
834
|
+
}
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
// Validator 8: Enhanced Escaped Character Detection
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
/**
|
|
839
|
+
* Detect escape chains and printf hex/octal patterns that can hide dangerous
|
|
840
|
+
* commands from string-level pattern matching.
|
|
841
|
+
*/
|
|
842
|
+
export function checkEnhancedEscapes(command, states) {
|
|
843
|
+
// Detect double-backslash before shell operators in unquoted context.
|
|
844
|
+
// In "echo hello\\;rm", the state machine marks both backslashes as escaped
|
|
845
|
+
// (the first escapes the second), leaving ";" as unquoted (none). We look
|
|
846
|
+
// for an escaped pair where the raw characters are both backslashes, followed
|
|
847
|
+
// immediately by an unquoted shell operator.
|
|
848
|
+
const shellOps = new Set([';', '&', '|']);
|
|
849
|
+
for (let i = 0; i + 2 < command.length; i++) {
|
|
850
|
+
if (states[i] === 'escaped' &&
|
|
851
|
+
states[i + 1] === 'escaped' &&
|
|
852
|
+
command[i] === '\\' &&
|
|
853
|
+
command[i + 1] === '\\' &&
|
|
854
|
+
states[i + 2] === 'none' &&
|
|
855
|
+
shellOps.has(command[i + 2])) {
|
|
856
|
+
return {
|
|
857
|
+
allowed: false,
|
|
858
|
+
reason: 'Obfuscation pattern detected: Double-escaped shell operator (live operator hidden behind escape chain)',
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Detect printf with hex/octal that spells dangerous commands.
|
|
863
|
+
// We look for printf calls with multiple escape sequences.
|
|
864
|
+
const printfMatch = command.match(/\bprintf\s+(['"])((?:\\x[0-9a-fA-F]{2}|\\[0-7]{3}){3,})\1/);
|
|
865
|
+
if (printfMatch) {
|
|
866
|
+
return {
|
|
867
|
+
allowed: false,
|
|
868
|
+
reason: 'Obfuscation pattern detected: printf with encoded character sequences',
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
// Also catch printf with %b and hex/octal in a variable
|
|
872
|
+
if (/\bprintf\s+['"]?%b['"]?\s+.*(?:\\x[0-9a-fA-F]{2}|\\[0-7]{3}){3,}/.test(command)) {
|
|
873
|
+
return {
|
|
874
|
+
allowed: false,
|
|
875
|
+
reason: 'Obfuscation pattern detected: printf %b with encoded character sequences',
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
return { allowed: true };
|
|
879
|
+
}
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
// Main obfuscation check
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
/**
|
|
884
|
+
* Check a command for obfuscation and injection patterns.
|
|
885
|
+
*/
|
|
886
|
+
export function checkObfuscation(command) {
|
|
887
|
+
// Strip invisible characters first
|
|
888
|
+
const cleaned = stripInvisibleChars(command);
|
|
889
|
+
// Check if the cleaned command differs significantly (invisible chars were present)
|
|
890
|
+
if (cleaned.length < command.length) {
|
|
891
|
+
return {
|
|
892
|
+
allowed: false,
|
|
893
|
+
reason: 'Command contains invisible Unicode characters that may be used for obfuscation.',
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
// Length check
|
|
897
|
+
if (command.length > 10000) {
|
|
898
|
+
return {
|
|
899
|
+
allowed: false,
|
|
900
|
+
reason: 'Command exceeds maximum length (10,000 characters).',
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
// Check download-and-execute pattern (curl | bash)
|
|
904
|
+
const hasPipeToShell = /\|\s*(ba)?sh\b/i.test(command) || /\|\s*\bsh\b/.test(command);
|
|
905
|
+
if (hasPipeToShell && /(curl|wget)\s+/i.test(command)) {
|
|
906
|
+
const urls = extractUrls(command);
|
|
907
|
+
if (urls.length === 1 && isSafeDownloadUrl(urls[0])) {
|
|
908
|
+
// Safe URL, allow
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
return {
|
|
912
|
+
allowed: false,
|
|
913
|
+
reason: 'Download-and-execute pattern detected (curl/wget piped to shell). This requires explicit approval.',
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Platform-specific patterns
|
|
918
|
+
const patterns = process.platform === 'win32'
|
|
919
|
+
? PS_OBFUSCATION_PATTERNS
|
|
920
|
+
: UNIX_OBFUSCATION_PATTERNS;
|
|
921
|
+
// Quote-stripped version for patterns where matches inside quoted strings
|
|
922
|
+
// are benign (e.g., backslash-escaped operators in grep regex patterns).
|
|
923
|
+
const unquotedCommand = stripQuotedContent(command);
|
|
924
|
+
for (const { pattern, description, quoteAware } of patterns) {
|
|
925
|
+
// Quote-aware patterns only match against unquoted portions of the command.
|
|
926
|
+
// e.g., "echo test\;rm -rf /" is obfuscation (unquoted), but
|
|
927
|
+
// "grep 'foo\|bar'" is legitimate grep regex (inside quotes).
|
|
928
|
+
const target = quoteAware ? unquotedCommand : command;
|
|
929
|
+
if (pattern.test(target)) {
|
|
930
|
+
return {
|
|
931
|
+
allowed: false,
|
|
932
|
+
reason: `Obfuscation pattern detected: ${description}`,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// --- Enhanced validators (quote-state-machine-powered) ---
|
|
937
|
+
const quoteStates = analyzeQuoteState(command);
|
|
938
|
+
// Validator 2: Enhanced IFS injection (quote-aware)
|
|
939
|
+
const ifsResult = checkIfsInjection(command, quoteStates);
|
|
940
|
+
if (!ifsResult.allowed)
|
|
941
|
+
return ifsResult;
|
|
942
|
+
// Validator 3: Enhanced proc/sys access (quote-aware)
|
|
943
|
+
const procResult = checkProcSysAccess(command, quoteStates);
|
|
944
|
+
if (!procResult.allowed)
|
|
945
|
+
return procResult;
|
|
946
|
+
// Validator 4: jq system() blocking
|
|
947
|
+
const jqResult = checkJqAbuse(command);
|
|
948
|
+
if (!jqResult.allowed)
|
|
949
|
+
return jqResult;
|
|
950
|
+
// Validator 5: ANSI-C quoting detection
|
|
951
|
+
const ansiCResult = checkAnsiCQuoting(command);
|
|
952
|
+
if (!ansiCResult.allowed)
|
|
953
|
+
return ansiCResult;
|
|
954
|
+
// Validator 6: Enhanced heredoc validation
|
|
955
|
+
const heredocResult = checkHeredocInjection(command);
|
|
956
|
+
if (!heredocResult.allowed)
|
|
957
|
+
return heredocResult;
|
|
958
|
+
// Validator 7: Brace expansion detection
|
|
959
|
+
const braceResult = checkBraceExpansion(command, quoteStates);
|
|
960
|
+
if (!braceResult.allowed)
|
|
961
|
+
return braceResult;
|
|
962
|
+
// Validator 8: Enhanced escaped character detection
|
|
963
|
+
const escapeResult = checkEnhancedEscapes(command, quoteStates);
|
|
964
|
+
if (!escapeResult.allowed)
|
|
965
|
+
return escapeResult;
|
|
966
|
+
return { allowed: true };
|
|
967
|
+
}
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
// Layer 6: Script Preflight
|
|
970
|
+
// ---------------------------------------------------------------------------
|
|
971
|
+
/**
|
|
972
|
+
* Check if a command is running a script file, and if so,
|
|
973
|
+
* scan the script for shell syntax bleed.
|
|
974
|
+
*/
|
|
975
|
+
export async function checkScriptPreflight(command, cwd) {
|
|
976
|
+
// Detect script execution patterns
|
|
977
|
+
const scriptPatterns = [
|
|
978
|
+
/^python[23]?\s+(\S+)/i,
|
|
979
|
+
/^node\s+(\S+)/i,
|
|
980
|
+
/^ts-node\s+(\S+)/i,
|
|
981
|
+
/^ruby\s+(\S+)/i,
|
|
982
|
+
/^perl\s+(\S+)/i,
|
|
983
|
+
];
|
|
984
|
+
for (const pattern of scriptPatterns) {
|
|
985
|
+
const match = command.match(pattern);
|
|
986
|
+
if (!match?.[1])
|
|
987
|
+
continue;
|
|
988
|
+
const scriptPath = path.resolve(cwd, match[1]);
|
|
989
|
+
try {
|
|
990
|
+
const content = await fs.promises.readFile(scriptPath, 'utf8');
|
|
991
|
+
const firstLines = content.split('\n').slice(0, 10);
|
|
992
|
+
// Check for bare $VARS in Python/JS files
|
|
993
|
+
const ext = path.extname(scriptPath).toLowerCase();
|
|
994
|
+
if (['.py', '.js', '.ts', '.mjs', '.cjs'].includes(ext)) {
|
|
995
|
+
for (const line of firstLines) {
|
|
996
|
+
// Shell variable patterns that don't belong in Python/JS
|
|
997
|
+
if (/^\s*\$[A-Z_]+\b/.test(line) && !/^\s*\/\//.test(line) && !/^\s*#/.test(line)) {
|
|
998
|
+
return {
|
|
999
|
+
allowed: false,
|
|
1000
|
+
reason: `Script ${scriptPath} contains shell variable syntax ($VAR) that may indicate shell syntax bleed.`,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// Check for shell commands at start of script
|
|
1006
|
+
if (['.py', '.js', '.ts'].includes(ext)) {
|
|
1007
|
+
const firstLine = (firstLines[0] ?? '').trim();
|
|
1008
|
+
if (/^(cd|ls|cat|echo|export|source|alias)\s/.test(firstLine) && !firstLine.startsWith('#!')) {
|
|
1009
|
+
return {
|
|
1010
|
+
allowed: false,
|
|
1011
|
+
reason: `Script ${scriptPath} starts with shell commands, suggesting mixed file contexts.`,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
// Can't read script file, skip check
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return { allowed: true };
|
|
1021
|
+
}
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
// Layer 7: Auto-Mode Classifier (Stub)
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
/**
|
|
1026
|
+
* Auto-mode classifier that uses the utility model to classify whether
|
|
1027
|
+
* a command should be blocked in autonomous mode.
|
|
1028
|
+
*
|
|
1029
|
+
* The full implementation will:
|
|
1030
|
+
* 1. Fast check (256 max tokens): quick classification
|
|
1031
|
+
* 2. Full analysis (4096 max tokens): if fast check is uncertain
|
|
1032
|
+
*
|
|
1033
|
+
* Fail-safe behavior: when auto-approve mode is active (isAutoApprove=true)
|
|
1034
|
+
* but no classifier function is available, this layer BLOCKS the command.
|
|
1035
|
+
* When auto-approve is not active, the consumer's permission resolver
|
|
1036
|
+
* (beforeToolCall) has already approved, so this layer passes through.
|
|
1037
|
+
*/
|
|
1038
|
+
export async function checkAutoModeClassifier(_command, _description, _utilityComplete, isAutoApprove) {
|
|
1039
|
+
// When auto-approve is not active, the consumer's permission system has
|
|
1040
|
+
// already handled approval. Layer 7 is defense-in-depth for auto mode only.
|
|
1041
|
+
if (!isAutoApprove) {
|
|
1042
|
+
return { allowed: true };
|
|
1043
|
+
}
|
|
1044
|
+
// Auto-approve is active but no classifier function is available.
|
|
1045
|
+
// Fail-safe: block until the classifier is fully implemented.
|
|
1046
|
+
if (!_utilityComplete) {
|
|
1047
|
+
return {
|
|
1048
|
+
allowed: false,
|
|
1049
|
+
reason: 'Auto-mode classifier not yet implemented. Command requires manual approval.',
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
// TODO: Full implementation will call utilityComplete for classification.
|
|
1053
|
+
// For now, block in auto-approve mode even with a utility model, since
|
|
1054
|
+
// the classification prompt/logic is not yet built.
|
|
1055
|
+
return {
|
|
1056
|
+
allowed: false,
|
|
1057
|
+
reason: 'Auto-mode classifier not yet implemented. Command requires manual approval.',
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
// Composite safety check
|
|
1062
|
+
// ---------------------------------------------------------------------------
|
|
1063
|
+
/**
|
|
1064
|
+
* Run all safety layers on a command.
|
|
1065
|
+
* Returns the first failure or { allowed: true } if all pass.
|
|
1066
|
+
*/
|
|
1067
|
+
export async function runSafetyChecks(command, workingDirectory, currentCwd, options) {
|
|
1068
|
+
// Layer 2: Critical path protection
|
|
1069
|
+
// Check each sub-command independently for critical path access
|
|
1070
|
+
const subCommands = splitOnShellOperators(command);
|
|
1071
|
+
for (const sub of subCommands) {
|
|
1072
|
+
const subTokens = sub.split(/\s+/);
|
|
1073
|
+
for (const token of subTokens) {
|
|
1074
|
+
if (token.startsWith('/') || token.startsWith('~') || (process.platform === 'win32' && /^[A-Za-z]:\\/.test(token))) {
|
|
1075
|
+
if (isCriticalPath(token)) {
|
|
1076
|
+
const subClassification = classifySingleCommand(sub);
|
|
1077
|
+
if (subClassification === 'write' || subClassification === 'create' || subClassification === 'unknown') {
|
|
1078
|
+
return {
|
|
1079
|
+
allowed: false,
|
|
1080
|
+
reason: 'This command would modify a critical system directory. This cannot be auto-allowed.',
|
|
1081
|
+
classification: classifyCommand(command),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Layer 4: Path validation for write commands (handles all sub-commands)
|
|
1089
|
+
const pathResult = validateWritePaths(command, workingDirectory, currentCwd);
|
|
1090
|
+
if (!pathResult.allowed)
|
|
1091
|
+
return pathResult;
|
|
1092
|
+
// Layer 5: Obfuscation detection
|
|
1093
|
+
const obfuscationResult = checkObfuscation(command);
|
|
1094
|
+
if (!obfuscationResult.allowed)
|
|
1095
|
+
return obfuscationResult;
|
|
1096
|
+
// Layer 6: Script preflight
|
|
1097
|
+
const scriptResult = await checkScriptPreflight(command, currentCwd);
|
|
1098
|
+
if (!scriptResult.allowed)
|
|
1099
|
+
return scriptResult;
|
|
1100
|
+
// Layer 6.5: Interactive command detection (UX gate, not security).
|
|
1101
|
+
// Sits after all security layers so obviously-malicious commands are
|
|
1102
|
+
// blocked first; prevents silent timeout loss on editors, pagers, and
|
|
1103
|
+
// REPLs.
|
|
1104
|
+
const interactiveResult = checkInteractive(command);
|
|
1105
|
+
if (!interactiveResult.allowed)
|
|
1106
|
+
return interactiveResult;
|
|
1107
|
+
// Layer 7: Auto-mode classifier
|
|
1108
|
+
const classifierResult = await checkAutoModeClassifier(command, options?.description, options?.utilityComplete, options?.isAutoApprove);
|
|
1109
|
+
if (!classifierResult.allowed)
|
|
1110
|
+
return classifierResult;
|
|
1111
|
+
return {
|
|
1112
|
+
allowed: true,
|
|
1113
|
+
classification: classifyCommand(command),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
//# sourceMappingURL=safety.js.map
|