neuro-cli 4.1.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 +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NeuroCLI - OS-Level Sandbox with Network Isolation (GAP-29)
|
|
3
|
+
// Docker-based and native OS sandboxing for secure execution
|
|
4
|
+
// Network policy engine, command filtering, audit logging
|
|
5
|
+
// ============================================================
|
|
6
|
+
import { execSync, spawn } from 'child_process';
|
|
7
|
+
import { resolve, normalize } from 'path';
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
9
|
+
import { platform } from 'os';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
// ---- Default Configuration ----
|
|
12
|
+
export const DEFAULT_OS_SANDBOX_CONFIG = {
|
|
13
|
+
type: 'docker',
|
|
14
|
+
docker: {
|
|
15
|
+
image: 'neurocli-sandbox:latest',
|
|
16
|
+
mountProject: true,
|
|
17
|
+
mountMode: 'rw',
|
|
18
|
+
networkMode: 'none',
|
|
19
|
+
allowedDomains: [],
|
|
20
|
+
memory: '512m',
|
|
21
|
+
cpus: 1,
|
|
22
|
+
timeout: 120000,
|
|
23
|
+
},
|
|
24
|
+
network: {
|
|
25
|
+
mode: 'filtered',
|
|
26
|
+
allowedDomains: [
|
|
27
|
+
'api.openai.com',
|
|
28
|
+
'api.anthropic.com',
|
|
29
|
+
'registry.npmjs.org',
|
|
30
|
+
'pypi.org',
|
|
31
|
+
'github.com',
|
|
32
|
+
],
|
|
33
|
+
allowedPorts: [80, 443],
|
|
34
|
+
blockPrivateNetworks: true,
|
|
35
|
+
},
|
|
36
|
+
filesystem: {
|
|
37
|
+
readOnlyPaths: ['/usr', '/lib', '/etc', '/bin', '/sbin'],
|
|
38
|
+
readWritePaths: [],
|
|
39
|
+
deniedPaths: [
|
|
40
|
+
'/etc/shadow',
|
|
41
|
+
'/etc/passwd',
|
|
42
|
+
'/etc/ssh',
|
|
43
|
+
'/root/.ssh',
|
|
44
|
+
'/home/*/.ssh',
|
|
45
|
+
],
|
|
46
|
+
allowHiddenFiles: false,
|
|
47
|
+
},
|
|
48
|
+
commands: {
|
|
49
|
+
blockedCommands: [
|
|
50
|
+
'rm -rf /',
|
|
51
|
+
'sudo',
|
|
52
|
+
'su',
|
|
53
|
+
'chmod 777 /',
|
|
54
|
+
'chmod 777 -R /',
|
|
55
|
+
'mkfs',
|
|
56
|
+
'dd if=',
|
|
57
|
+
':(){ :|:& };:',
|
|
58
|
+
'curl | sh',
|
|
59
|
+
'wget | sh',
|
|
60
|
+
'passwd',
|
|
61
|
+
'shutdown',
|
|
62
|
+
'reboot',
|
|
63
|
+
'halt',
|
|
64
|
+
'init',
|
|
65
|
+
'telinit',
|
|
66
|
+
'systemctl',
|
|
67
|
+
'service',
|
|
68
|
+
],
|
|
69
|
+
allowedPrefixes: [],
|
|
70
|
+
maxArgLength: 8192,
|
|
71
|
+
allowPipes: true,
|
|
72
|
+
allowBackground: false,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
// ---- Private Network Ranges for Filtering ----
|
|
76
|
+
const PRIVATE_NETWORK_RANGES = [
|
|
77
|
+
{ start: ipToNumber('10.0.0.0'), end: ipToNumber('10.255.255.255') },
|
|
78
|
+
{ start: ipToNumber('172.16.0.0'), end: ipToNumber('172.31.255.255') },
|
|
79
|
+
{ start: ipToNumber('192.168.0.0'), end: ipToNumber('192.168.255.255') },
|
|
80
|
+
{ start: ipToNumber('127.0.0.0'), end: ipToNumber('127.255.255.255') },
|
|
81
|
+
{ start: ipToNumber('169.254.0.0'), end: ipToNumber('169.254.255.255') },
|
|
82
|
+
{ start: ipToNumber('0.0.0.0'), end: ipToNumber('0.255.255.255') },
|
|
83
|
+
];
|
|
84
|
+
function ipToNumber(ip) {
|
|
85
|
+
const parts = ip.split('.').map(Number);
|
|
86
|
+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
87
|
+
}
|
|
88
|
+
function isPrivateIP(ip) {
|
|
89
|
+
const num = ipToNumber(ip);
|
|
90
|
+
return PRIVATE_NETWORK_RANGES.some(range => num >= range.start && num <= range.end);
|
|
91
|
+
}
|
|
92
|
+
// ---- Docker Sandbox Image Dockerfile ----
|
|
93
|
+
const SANDBOX_DOCKERFILE = `FROM alpine:3.19
|
|
94
|
+
RUN apk add --no-cache bash coreutils curl wget git nodejs npm python3 py3-pip
|
|
95
|
+
RUN adduser -D -s /bin/bash sandbox
|
|
96
|
+
USER sandbox
|
|
97
|
+
WORKDIR /workspace
|
|
98
|
+
`;
|
|
99
|
+
// ---- Network Policy Engine ----
|
|
100
|
+
class NetworkPolicyEngine {
|
|
101
|
+
allowedDomains;
|
|
102
|
+
allowedPorts;
|
|
103
|
+
blockPrivateNetworks;
|
|
104
|
+
mode;
|
|
105
|
+
accessLog = [];
|
|
106
|
+
rateLimiter = new Map();
|
|
107
|
+
maxRequestsPerMinute = 60;
|
|
108
|
+
constructor(config) {
|
|
109
|
+
this.mode = config?.mode ?? 'filtered';
|
|
110
|
+
this.allowedDomains = new Set(config?.allowedDomains ?? []);
|
|
111
|
+
this.allowedPorts = new Set(config?.allowedPorts ?? [80, 443]);
|
|
112
|
+
this.blockPrivateNetworks = config?.blockPrivateNetworks ?? true;
|
|
113
|
+
}
|
|
114
|
+
validate(host, port, protocol = 'tcp') {
|
|
115
|
+
const entry = {
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
host,
|
|
118
|
+
port,
|
|
119
|
+
protocol,
|
|
120
|
+
action: 'allowed',
|
|
121
|
+
};
|
|
122
|
+
if (this.mode === 'blocked') {
|
|
123
|
+
entry.action = 'blocked';
|
|
124
|
+
entry.reason = 'All network access is blocked';
|
|
125
|
+
this.accessLog.push(entry);
|
|
126
|
+
return { allowed: false, reason: entry.reason };
|
|
127
|
+
}
|
|
128
|
+
if (this.mode === 'open') {
|
|
129
|
+
this.accessLog.push(entry);
|
|
130
|
+
return { allowed: true };
|
|
131
|
+
}
|
|
132
|
+
// mode === 'filtered'
|
|
133
|
+
// Check private network blocking
|
|
134
|
+
if (this.blockPrivateNetworks) {
|
|
135
|
+
const resolvedIP = this.resolveHost(host);
|
|
136
|
+
if (resolvedIP && isPrivateIP(resolvedIP)) {
|
|
137
|
+
entry.action = 'blocked';
|
|
138
|
+
entry.reason = `Access to private network address ${resolvedIP} is blocked`;
|
|
139
|
+
this.accessLog.push(entry);
|
|
140
|
+
return { allowed: false, reason: entry.reason };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Check allowed domains
|
|
144
|
+
if (this.allowedDomains.size > 0) {
|
|
145
|
+
const isDomainAllowed = this.isDomainMatch(host);
|
|
146
|
+
if (!isDomainAllowed) {
|
|
147
|
+
entry.action = 'blocked';
|
|
148
|
+
entry.reason = `Domain ${host} is not in the allowed list`;
|
|
149
|
+
this.accessLog.push(entry);
|
|
150
|
+
return { allowed: false, reason: entry.reason };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Check allowed ports
|
|
154
|
+
if (this.allowedPorts.size > 0 && !this.allowedPorts.has(port)) {
|
|
155
|
+
entry.action = 'blocked';
|
|
156
|
+
entry.reason = `Port ${port} is not in the allowed list`;
|
|
157
|
+
this.accessLog.push(entry);
|
|
158
|
+
return { allowed: false, reason: entry.reason };
|
|
159
|
+
}
|
|
160
|
+
// Rate limiting
|
|
161
|
+
if (!this.checkRateLimit(host)) {
|
|
162
|
+
entry.action = 'blocked';
|
|
163
|
+
entry.reason = `Rate limit exceeded for ${host}`;
|
|
164
|
+
this.accessLog.push(entry);
|
|
165
|
+
return { allowed: false, reason: entry.reason };
|
|
166
|
+
}
|
|
167
|
+
this.accessLog.push(entry);
|
|
168
|
+
return { allowed: true };
|
|
169
|
+
}
|
|
170
|
+
getAccessLog() {
|
|
171
|
+
return [...this.accessLog];
|
|
172
|
+
}
|
|
173
|
+
clearLog() {
|
|
174
|
+
this.accessLog = [];
|
|
175
|
+
}
|
|
176
|
+
isDomainMatch(host) {
|
|
177
|
+
if (this.allowedDomains.has(host))
|
|
178
|
+
return true;
|
|
179
|
+
for (const allowed of this.allowedDomains) {
|
|
180
|
+
if (host.endsWith('.' + allowed))
|
|
181
|
+
return true;
|
|
182
|
+
if (allowed.startsWith('*.') && host.endsWith(allowed.slice(1)))
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
resolveHost(host) {
|
|
188
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
|
|
189
|
+
return host;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const result = execSync(`nslookup ${host} 2>/dev/null`, {
|
|
193
|
+
encoding: 'utf-8',
|
|
194
|
+
timeout: 5000,
|
|
195
|
+
});
|
|
196
|
+
const match = result.match(/Address:\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g);
|
|
197
|
+
if (match && match.length > 1) {
|
|
198
|
+
const ip = match[match.length - 1].replace('Address:\s*', '').trim();
|
|
199
|
+
return ip || null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// DNS resolution failed, can't determine if private
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
checkRateLimit(host) {
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const windowStart = now - 60000;
|
|
210
|
+
const timestamps = this.rateLimiter.get(host) ?? [];
|
|
211
|
+
const recent = timestamps.filter(ts => ts > windowStart);
|
|
212
|
+
recent.push(now);
|
|
213
|
+
this.rateLimiter.set(host, recent);
|
|
214
|
+
return recent.length <= this.maxRequestsPerMinute;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// ---- Command Filter ----
|
|
218
|
+
class CommandFilter {
|
|
219
|
+
blockedCommands;
|
|
220
|
+
allowedPrefixes;
|
|
221
|
+
maxArgLength;
|
|
222
|
+
allowPipes;
|
|
223
|
+
allowBackground;
|
|
224
|
+
static DANGEROUS_PATTERNS = [
|
|
225
|
+
/rm\s+-rf\s+\//,
|
|
226
|
+
/mkfs/,
|
|
227
|
+
/dd\s+if=/,
|
|
228
|
+
/:\(\)\{.*;\}/,
|
|
229
|
+
/wget.*\|\s*sh/,
|
|
230
|
+
/curl.*\|\s*sh/,
|
|
231
|
+
/chmod\s+-R\s+777/,
|
|
232
|
+
/chmod\s+777\s+\//,
|
|
233
|
+
/>\s*\/dev\/sda/,
|
|
234
|
+
/mv\s+.*\s+\/dev\/null/,
|
|
235
|
+
];
|
|
236
|
+
constructor(config) {
|
|
237
|
+
this.blockedCommands = config?.blockedCommands ?? DEFAULT_OS_SANDBOX_CONFIG.commands.blockedCommands;
|
|
238
|
+
this.allowedPrefixes = config?.allowedPrefixes ?? [];
|
|
239
|
+
this.maxArgLength = config?.maxArgLength ?? 8192;
|
|
240
|
+
this.allowPipes = config?.allowPipes ?? true;
|
|
241
|
+
this.allowBackground = config?.allowBackground ?? false;
|
|
242
|
+
}
|
|
243
|
+
validate(command) {
|
|
244
|
+
const trimmed = command.trim();
|
|
245
|
+
// Check argument length
|
|
246
|
+
if (trimmed.length > this.maxArgLength) {
|
|
247
|
+
return { allowed: false, reason: `Command exceeds maximum length of ${this.maxArgLength} characters` };
|
|
248
|
+
}
|
|
249
|
+
// Check blocked commands list
|
|
250
|
+
for (const blocked of this.blockedCommands) {
|
|
251
|
+
if (trimmed.includes(blocked) || trimmed.startsWith(blocked.split(' ')[0])) {
|
|
252
|
+
return { allowed: false, reason: `Blocked command pattern: "${blocked}"` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Check dangerous patterns
|
|
256
|
+
for (const pattern of CommandFilter.DANGEROUS_PATTERNS) {
|
|
257
|
+
if (pattern.test(trimmed)) {
|
|
258
|
+
return { allowed: false, reason: 'Command matches a dangerous pattern' };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Check for shell injection attempts
|
|
262
|
+
const injectionPatterns = [
|
|
263
|
+
/\$\(.*\)/, // Command substitution
|
|
264
|
+
/`.*`/, // Backtick command substitution
|
|
265
|
+
/&&\s*rm/, // Chained destructive commands
|
|
266
|
+
/\|\s*sh\b/, // Pipe to shell
|
|
267
|
+
];
|
|
268
|
+
for (const pattern of injectionPatterns) {
|
|
269
|
+
if (pattern.test(trimmed)) {
|
|
270
|
+
return { allowed: false, reason: 'Potential shell injection detected' };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Check pipe restrictions
|
|
274
|
+
if (!this.allowPipes && trimmed.includes('|')) {
|
|
275
|
+
return { allowed: false, reason: 'Pipes are not allowed in sandbox mode' };
|
|
276
|
+
}
|
|
277
|
+
// Check background execution
|
|
278
|
+
if (!this.allowBackground && trimmed.includes('&')) {
|
|
279
|
+
const ampersands = trimmed.match(/[^&]&[^&]|&$/g);
|
|
280
|
+
if (ampersands) {
|
|
281
|
+
return { allowed: false, reason: 'Background execution is not allowed in sandbox mode' };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Check allowed prefixes (if set)
|
|
285
|
+
if (this.allowedPrefixes.length > 0) {
|
|
286
|
+
const cmdBase = trimmed.split(/\s+/)[0];
|
|
287
|
+
const isAllowed = this.allowedPrefixes.some(prefix => cmdBase === prefix || trimmed.startsWith(prefix));
|
|
288
|
+
if (!isAllowed) {
|
|
289
|
+
return { allowed: false, reason: `Command "${cmdBase}" is not in the allowed prefixes list` };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { allowed: true };
|
|
293
|
+
}
|
|
294
|
+
sanitizeEnv(env) {
|
|
295
|
+
const sensitiveKeys = [
|
|
296
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
297
|
+
'AWS_ACCESS_KEY_ID',
|
|
298
|
+
'API_KEY',
|
|
299
|
+
'SECRET',
|
|
300
|
+
'TOKEN',
|
|
301
|
+
'PASSWORD',
|
|
302
|
+
'PRIVATE_KEY',
|
|
303
|
+
'DATABASE_URL',
|
|
304
|
+
'MONGODB_URI',
|
|
305
|
+
'REDIS_URL',
|
|
306
|
+
];
|
|
307
|
+
const sanitized = {};
|
|
308
|
+
for (const [key, value] of Object.entries(env)) {
|
|
309
|
+
const isSensitive = sensitiveKeys.some(s => key.toUpperCase().includes(s));
|
|
310
|
+
if (!isSensitive) {
|
|
311
|
+
sanitized[key] = value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return sanitized;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// ---- Filesystem Policy ----
|
|
318
|
+
class FilesystemPolicy {
|
|
319
|
+
readOnlyPaths;
|
|
320
|
+
readWritePaths;
|
|
321
|
+
deniedPaths;
|
|
322
|
+
allowHiddenFiles;
|
|
323
|
+
accessLog = [];
|
|
324
|
+
constructor(config) {
|
|
325
|
+
this.readOnlyPaths = config?.readOnlyPaths ?? [];
|
|
326
|
+
this.readWritePaths = config?.readWritePaths ?? [];
|
|
327
|
+
this.deniedPaths = config?.deniedPaths ?? DEFAULT_OS_SANDBOX_CONFIG.filesystem.deniedPaths;
|
|
328
|
+
this.allowHiddenFiles = config?.allowHiddenFiles ?? false;
|
|
329
|
+
}
|
|
330
|
+
validate(path, mode) {
|
|
331
|
+
const absPath = normalize(resolve(path));
|
|
332
|
+
const entry = {
|
|
333
|
+
timestamp: Date.now(),
|
|
334
|
+
path: absPath,
|
|
335
|
+
mode,
|
|
336
|
+
action: 'allowed',
|
|
337
|
+
};
|
|
338
|
+
// Check denied paths
|
|
339
|
+
for (const denied of this.deniedPaths) {
|
|
340
|
+
const deniedPattern = denied.replace(/\*/g, '.*');
|
|
341
|
+
try {
|
|
342
|
+
const regex = new RegExp('^' + deniedPattern + '$');
|
|
343
|
+
if (regex.test(absPath)) {
|
|
344
|
+
entry.action = 'blocked';
|
|
345
|
+
entry.reason = `Path matches denied pattern: ${denied}`;
|
|
346
|
+
this.accessLog.push(entry);
|
|
347
|
+
return { allowed: false, reason: entry.reason };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Invalid pattern, skip
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Check hidden files
|
|
355
|
+
if (!this.allowHiddenFiles) {
|
|
356
|
+
const parts = absPath.split('/');
|
|
357
|
+
if (parts.some(part => part.startsWith('.') && part !== '.' && part !== '..')) {
|
|
358
|
+
entry.action = 'blocked';
|
|
359
|
+
entry.reason = 'Access to hidden files is not allowed';
|
|
360
|
+
this.accessLog.push(entry);
|
|
361
|
+
return { allowed: false, reason: entry.reason };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// For write and delete, check read-only paths
|
|
365
|
+
if (mode === 'write' || mode === 'delete') {
|
|
366
|
+
for (const roPath of this.readOnlyPaths) {
|
|
367
|
+
if (absPath.startsWith(roPath)) {
|
|
368
|
+
// Check if explicitly in read-write paths
|
|
369
|
+
const isExplicitlyRW = this.readWritePaths.some(rwPath => absPath.startsWith(rwPath));
|
|
370
|
+
if (!isExplicitlyRW) {
|
|
371
|
+
entry.action = 'blocked';
|
|
372
|
+
entry.reason = `Path is read-only: ${roPath}`;
|
|
373
|
+
this.accessLog.push(entry);
|
|
374
|
+
return { allowed: false, reason: entry.reason };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
this.accessLog.push(entry);
|
|
380
|
+
return { allowed: true };
|
|
381
|
+
}
|
|
382
|
+
getAccessLog() {
|
|
383
|
+
return [...this.accessLog];
|
|
384
|
+
}
|
|
385
|
+
clearLog() {
|
|
386
|
+
this.accessLog = [];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// ---- Main OSSandboxManager Class ----
|
|
390
|
+
export class OSSandboxManager {
|
|
391
|
+
config;
|
|
392
|
+
networkPolicy;
|
|
393
|
+
commandFilter;
|
|
394
|
+
filesystemPolicy;
|
|
395
|
+
auditLog = [];
|
|
396
|
+
dockerAvailable = null;
|
|
397
|
+
sandboxImageBuilt = false;
|
|
398
|
+
activeContainers = new Map();
|
|
399
|
+
projectDir;
|
|
400
|
+
constructor(config = {}) {
|
|
401
|
+
this.config = this.mergeConfig(config);
|
|
402
|
+
this.networkPolicy = new NetworkPolicyEngine(this.config.network);
|
|
403
|
+
this.commandFilter = new CommandFilter(this.config.commands);
|
|
404
|
+
this.filesystemPolicy = new FilesystemPolicy(this.config.filesystem);
|
|
405
|
+
this.projectDir = process.cwd();
|
|
406
|
+
}
|
|
407
|
+
// ---- Docker Availability ----
|
|
408
|
+
async isDockerAvailable() {
|
|
409
|
+
if (this.dockerAvailable !== null)
|
|
410
|
+
return this.dockerAvailable;
|
|
411
|
+
try {
|
|
412
|
+
const result = execSync('docker info --format "{{.ServerVersion}}" 2>/dev/null', {
|
|
413
|
+
encoding: 'utf-8',
|
|
414
|
+
timeout: 5000,
|
|
415
|
+
});
|
|
416
|
+
this.dockerAvailable = result.trim().length > 0;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
this.dockerAvailable = false;
|
|
420
|
+
}
|
|
421
|
+
this.addAuditLog('container', 'docker_availability_check', `Docker is ${this.dockerAvailable ? 'available' : 'not available'}`, true);
|
|
422
|
+
return this.dockerAvailable;
|
|
423
|
+
}
|
|
424
|
+
// ---- Native Sandbox Availability ----
|
|
425
|
+
isNativeSandboxAvailable() {
|
|
426
|
+
const currentPlatform = platform();
|
|
427
|
+
if (currentPlatform === 'linux') {
|
|
428
|
+
try {
|
|
429
|
+
execSync('unshare --help 2>/dev/null', { encoding: 'utf-8', timeout: 3000 });
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (currentPlatform === 'darwin') {
|
|
437
|
+
try {
|
|
438
|
+
execSync('sandbox-exec -n no-network /bin/true 2>/dev/null', {
|
|
439
|
+
encoding: 'utf-8',
|
|
440
|
+
timeout: 3000,
|
|
441
|
+
});
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
// ---- Command Execution ----
|
|
451
|
+
async execute(command, options) {
|
|
452
|
+
const startTime = Date.now();
|
|
453
|
+
// Validate command against filter
|
|
454
|
+
const cmdValidation = this.validateCommand(command);
|
|
455
|
+
if (!cmdValidation.allowed) {
|
|
456
|
+
this.addAuditLog('command', 'execute_blocked', command, false, cmdValidation.reason);
|
|
457
|
+
return {
|
|
458
|
+
stdout: '',
|
|
459
|
+
stderr: `Sandbox: Command blocked - ${cmdValidation.reason}`,
|
|
460
|
+
exitCode: 126,
|
|
461
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
462
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
463
|
+
duration: Date.now() - startTime,
|
|
464
|
+
timedOut: false,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
this.addAuditLog('command', 'execute', command, true);
|
|
468
|
+
const timeout = options?.timeout ?? this.config.docker?.timeout ?? 120000;
|
|
469
|
+
try {
|
|
470
|
+
if (this.config.type === 'docker') {
|
|
471
|
+
return await this.executeInDocker(command, options, timeout, startTime);
|
|
472
|
+
}
|
|
473
|
+
if (this.config.type === 'os-native') {
|
|
474
|
+
return await this.executeNative(command, options, timeout, startTime);
|
|
475
|
+
}
|
|
476
|
+
// hybrid: try docker first, fall back to native
|
|
477
|
+
if (await this.isDockerAvailable()) {
|
|
478
|
+
return await this.executeInDocker(command, options, timeout, startTime);
|
|
479
|
+
}
|
|
480
|
+
if (this.isNativeSandboxAvailable()) {
|
|
481
|
+
return await this.executeNative(command, options, timeout, startTime);
|
|
482
|
+
}
|
|
483
|
+
// No sandboxing available, execute with filtering only
|
|
484
|
+
return await this.executeFiltered(command, options, timeout, startTime);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
return {
|
|
488
|
+
stdout: '',
|
|
489
|
+
stderr: `Sandbox execution error: ${error.message}`,
|
|
490
|
+
exitCode: 1,
|
|
491
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
492
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
493
|
+
duration: Date.now() - startTime,
|
|
494
|
+
timedOut: false,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// ---- Container Lifecycle ----
|
|
499
|
+
async createContainer() {
|
|
500
|
+
if (!(await this.isDockerAvailable())) {
|
|
501
|
+
throw new Error('Docker is not available for container creation');
|
|
502
|
+
}
|
|
503
|
+
await this.ensureSandboxImage();
|
|
504
|
+
const dockerConfig = this.config.docker;
|
|
505
|
+
const args = ['create'];
|
|
506
|
+
// Resource limits
|
|
507
|
+
args.push('--memory', dockerConfig.memory);
|
|
508
|
+
args.push('--cpus', String(dockerConfig.cpus));
|
|
509
|
+
// Network configuration
|
|
510
|
+
if (dockerConfig.networkMode === 'none') {
|
|
511
|
+
args.push('--network', 'none');
|
|
512
|
+
}
|
|
513
|
+
else if (dockerConfig.networkMode === 'limited') {
|
|
514
|
+
// Use bridge network with iptables rules applied later
|
|
515
|
+
args.push('--network', 'bridge');
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
args.push('--network', 'bridge');
|
|
519
|
+
}
|
|
520
|
+
// DNS configuration for filtered mode
|
|
521
|
+
if (dockerConfig.networkMode === 'limited') {
|
|
522
|
+
args.push('--dns', '127.0.0.1');
|
|
523
|
+
}
|
|
524
|
+
// Security options
|
|
525
|
+
args.push('--security-opt', 'no-new-privileges');
|
|
526
|
+
args.push('--cap-drop', 'ALL');
|
|
527
|
+
args.push('--cap-add', 'CHOWN');
|
|
528
|
+
args.push('--cap-add', 'SETUID');
|
|
529
|
+
args.push('--cap-add', 'SETGID');
|
|
530
|
+
args.push('--cap-add', 'DAC_OVERRIDE');
|
|
531
|
+
args.push('--pids-limit', '64');
|
|
532
|
+
args.push('--read-only');
|
|
533
|
+
// Mount project directory
|
|
534
|
+
if (dockerConfig.mountProject) {
|
|
535
|
+
const mountMode = dockerConfig.mountMode === 'ro' ? ':ro' : ':rw';
|
|
536
|
+
args.push('-v', `${this.projectDir}:/workspace${mountMode}`);
|
|
537
|
+
}
|
|
538
|
+
// Tmpfs for write paths
|
|
539
|
+
args.push('--tmpfs', '/tmp:rw,noexec,nosuid,size=64m');
|
|
540
|
+
args.push('--tmpfs', '/home/sandbox:rw,noexec,nosuid,size=64m');
|
|
541
|
+
// Working directory
|
|
542
|
+
args.push('-w', '/workspace');
|
|
543
|
+
// Image
|
|
544
|
+
args.push(dockerConfig.image);
|
|
545
|
+
// Default command: keep container alive
|
|
546
|
+
args.push('tail', '-f', '/dev/null');
|
|
547
|
+
try {
|
|
548
|
+
const containerId = execSync(`docker ${args.join(' ')}`, {
|
|
549
|
+
encoding: 'utf-8',
|
|
550
|
+
timeout: 30000,
|
|
551
|
+
}).trim();
|
|
552
|
+
this.activeContainers.set(containerId, {
|
|
553
|
+
id: containerId,
|
|
554
|
+
startedAt: Date.now(),
|
|
555
|
+
});
|
|
556
|
+
// Apply network rules for limited mode
|
|
557
|
+
if (dockerConfig.networkMode === 'limited') {
|
|
558
|
+
await this.applyContainerNetworkRules(containerId);
|
|
559
|
+
}
|
|
560
|
+
this.addAuditLog('container', 'create', `Container ${containerId.substring(0, 12)} created`, true);
|
|
561
|
+
return containerId;
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
this.addAuditLog('container', 'create_failed', error.message, false);
|
|
565
|
+
throw new Error(`Failed to create container: ${error.message}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async stopContainer(id) {
|
|
569
|
+
try {
|
|
570
|
+
execSync(`docker stop ${id} 2>/dev/null`, { encoding: 'utf-8', timeout: 10000 });
|
|
571
|
+
execSync(`docker rm ${id} 2>/dev/null`, { encoding: 'utf-8', timeout: 10000 });
|
|
572
|
+
this.activeContainers.delete(id);
|
|
573
|
+
this.addAuditLog('container', 'stop', `Container ${id.substring(0, 12)} stopped and removed`, true);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
this.addAuditLog('container', 'stop_failed', `Failed to stop container ${id.substring(0, 12)}: ${error.message}`, false);
|
|
577
|
+
throw new Error(`Failed to stop container: ${error.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// ---- Validation Methods ----
|
|
581
|
+
validateCommand(command) {
|
|
582
|
+
return this.commandFilter.validate(command);
|
|
583
|
+
}
|
|
584
|
+
validatePath(path, mode) {
|
|
585
|
+
return this.filesystemPolicy.validate(path, mode);
|
|
586
|
+
}
|
|
587
|
+
validateNetwork(host, port) {
|
|
588
|
+
return this.networkPolicy.validate(host, port);
|
|
589
|
+
}
|
|
590
|
+
// ---- Audit Log ----
|
|
591
|
+
getAuditLog() {
|
|
592
|
+
return [...this.auditLog];
|
|
593
|
+
}
|
|
594
|
+
clearAuditLog() {
|
|
595
|
+
this.auditLog = [];
|
|
596
|
+
this.networkPolicy.clearLog();
|
|
597
|
+
this.filesystemPolicy.clearLog();
|
|
598
|
+
}
|
|
599
|
+
// ---- Cleanup ----
|
|
600
|
+
async cleanup() {
|
|
601
|
+
const containerIds = [...this.activeContainers.keys()];
|
|
602
|
+
for (const id of containerIds) {
|
|
603
|
+
try {
|
|
604
|
+
await this.stopContainer(id);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// Best-effort cleanup
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
this.addAuditLog('config', 'cleanup', 'All containers cleaned up', true);
|
|
611
|
+
}
|
|
612
|
+
// ---- Print Status ----
|
|
613
|
+
printStatus() {
|
|
614
|
+
const typeLabel = this.config.type.toUpperCase();
|
|
615
|
+
console.log(chalk.bold(`\n OS-Level Sandbox: ${chalk.cyan(typeLabel)}`));
|
|
616
|
+
if (this.config.type === 'docker' || this.config.type === 'hybrid') {
|
|
617
|
+
const dockerStatus = this.dockerAvailable === null
|
|
618
|
+
? chalk.yellow('NOT CHECKED')
|
|
619
|
+
: this.dockerAvailable
|
|
620
|
+
? chalk.green('AVAILABLE')
|
|
621
|
+
: chalk.red('NOT AVAILABLE');
|
|
622
|
+
console.log(` Docker: ${dockerStatus}`);
|
|
623
|
+
if (this.config.docker) {
|
|
624
|
+
console.log(` Image: ${chalk.cyan(this.config.docker.image)}`);
|
|
625
|
+
console.log(` Mount: ${this.config.docker.mountProject ? chalk.green('YES') : chalk.gray('NO')} (${this.config.docker.mountMode})`);
|
|
626
|
+
console.log(` Network: ${chalk.yellow(this.config.docker.networkMode)}`);
|
|
627
|
+
console.log(` Memory: ${this.config.docker.memory}, CPUs: ${this.config.docker.cpus}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (this.config.type === 'os-native' || this.config.type === 'hybrid') {
|
|
631
|
+
const nativeStatus = this.isNativeSandboxAvailable()
|
|
632
|
+
? chalk.green('AVAILABLE')
|
|
633
|
+
: chalk.red('NOT AVAILABLE');
|
|
634
|
+
console.log(` Native Sandbox: ${nativeStatus}`);
|
|
635
|
+
}
|
|
636
|
+
if (this.config.network) {
|
|
637
|
+
console.log(` Network Mode: ${chalk.yellow(this.config.network.mode)}`);
|
|
638
|
+
if (this.config.network.mode === 'filtered') {
|
|
639
|
+
console.log(` Allowed Domains: ${this.config.network.allowedDomains.length > 0
|
|
640
|
+
? chalk.cyan(this.config.network.allowedDomains.join(', '))
|
|
641
|
+
: chalk.gray('none')}`);
|
|
642
|
+
console.log(` Allowed Ports: ${this.config.network.allowedPorts.length > 0
|
|
643
|
+
? chalk.cyan(this.config.network.allowedPorts.join(', '))
|
|
644
|
+
: chalk.gray('all')}`);
|
|
645
|
+
console.log(` Block Private Networks: ${this.config.network.blockPrivateNetworks
|
|
646
|
+
? chalk.green('YES')
|
|
647
|
+
: chalk.red('NO')}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (this.config.commands) {
|
|
651
|
+
console.log(` Blocked Commands: ${chalk.red(String(this.config.commands.blockedCommands.length))}`);
|
|
652
|
+
console.log(` Allow Pipes: ${this.config.commands.allowPipes ? chalk.green('YES') : chalk.red('NO')}`);
|
|
653
|
+
console.log(` Allow Background: ${this.config.commands.allowBackground ? chalk.green('YES') : chalk.red('NO')}`);
|
|
654
|
+
}
|
|
655
|
+
if (this.activeContainers.size > 0) {
|
|
656
|
+
console.log(` Active Containers: ${chalk.green(String(this.activeContainers.size))}`);
|
|
657
|
+
}
|
|
658
|
+
if (this.auditLog.length > 0) {
|
|
659
|
+
const blocked = this.auditLog.filter(e => !e.allowed).length;
|
|
660
|
+
console.log(` Audit Entries: ${this.auditLog.length} (${chalk.red(String(blocked))} blocked)`);
|
|
661
|
+
}
|
|
662
|
+
console.log();
|
|
663
|
+
}
|
|
664
|
+
// ============================================================
|
|
665
|
+
// Private Methods
|
|
666
|
+
// ============================================================
|
|
667
|
+
mergeConfig(partial) {
|
|
668
|
+
const base = { ...DEFAULT_OS_SANDBOX_CONFIG };
|
|
669
|
+
return {
|
|
670
|
+
...base,
|
|
671
|
+
...partial,
|
|
672
|
+
docker: { ...base.docker, ...partial.docker },
|
|
673
|
+
network: { ...base.network, ...partial.network },
|
|
674
|
+
filesystem: { ...base.filesystem, ...partial.filesystem },
|
|
675
|
+
commands: { ...base.commands, ...partial.commands },
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
addAuditLog(category, action, detail, allowed, reason) {
|
|
679
|
+
this.auditLog.push({
|
|
680
|
+
timestamp: Date.now(),
|
|
681
|
+
category,
|
|
682
|
+
action,
|
|
683
|
+
detail,
|
|
684
|
+
allowed,
|
|
685
|
+
reason,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// ---- Docker Execution ----
|
|
689
|
+
async executeInDocker(command, options, timeout, startTime) {
|
|
690
|
+
if (!(await this.isDockerAvailable())) {
|
|
691
|
+
if (this.config.type === 'docker') {
|
|
692
|
+
return {
|
|
693
|
+
stdout: '',
|
|
694
|
+
stderr: 'Docker is not available. Cannot run in Docker sandbox mode.',
|
|
695
|
+
exitCode: 127,
|
|
696
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
697
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
698
|
+
duration: Date.now() - startTime,
|
|
699
|
+
timedOut: false,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
// Fall back to filtered execution for hybrid mode
|
|
703
|
+
return this.executeFiltered(command, options, timeout, startTime);
|
|
704
|
+
}
|
|
705
|
+
await this.ensureSandboxImage();
|
|
706
|
+
const dockerConfig = this.config.docker;
|
|
707
|
+
const args = ['run', '--rm'];
|
|
708
|
+
// Resource limits
|
|
709
|
+
args.push('--memory', dockerConfig.memory);
|
|
710
|
+
args.push('--memory-swap', dockerConfig.memory);
|
|
711
|
+
args.push('--cpus', String(dockerConfig.cpus));
|
|
712
|
+
// Network configuration
|
|
713
|
+
if (dockerConfig.networkMode === 'none') {
|
|
714
|
+
args.push('--network', 'none');
|
|
715
|
+
}
|
|
716
|
+
else if (dockerConfig.networkMode === 'limited') {
|
|
717
|
+
args.push('--network', 'bridge');
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
args.push('--network', 'bridge');
|
|
721
|
+
}
|
|
722
|
+
// Security options
|
|
723
|
+
args.push('--security-opt', 'no-new-privileges');
|
|
724
|
+
args.push('--cap-drop', 'ALL');
|
|
725
|
+
args.push('--cap-add', 'CHOWN');
|
|
726
|
+
args.push('--cap-add', 'SETUID');
|
|
727
|
+
args.push('--cap-add', 'SETGID');
|
|
728
|
+
args.push('--cap-add', 'DAC_OVERRIDE');
|
|
729
|
+
args.push('--pids-limit', '64');
|
|
730
|
+
// Mount project directory
|
|
731
|
+
if (dockerConfig.mountProject) {
|
|
732
|
+
const mountMode = dockerConfig.mountMode === 'ro' ? ':ro' : ':rw';
|
|
733
|
+
args.push('-v', `${this.projectDir}:/workspace${mountMode}`);
|
|
734
|
+
}
|
|
735
|
+
// Tmpfs for temporary writes
|
|
736
|
+
args.push('--tmpfs', '/tmp:rw,noexec,nosuid,size=64m');
|
|
737
|
+
// Working directory
|
|
738
|
+
args.push('-w', '/workspace');
|
|
739
|
+
// Environment variables (sanitized)
|
|
740
|
+
if (options?.env) {
|
|
741
|
+
const sanitizedEnv = this.commandFilter.sanitizeEnv(options.env);
|
|
742
|
+
for (const [key, value] of Object.entries(sanitizedEnv)) {
|
|
743
|
+
args.push('-e', `${key}=${value}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Timeout
|
|
747
|
+
const dockerTimeout = Math.min(timeout, 300000);
|
|
748
|
+
// Image
|
|
749
|
+
args.push(dockerConfig.image);
|
|
750
|
+
// Command to execute
|
|
751
|
+
args.push('sh', '-c', command);
|
|
752
|
+
this.addAuditLog('command', 'docker_execute', `Executing in Docker: ${command.substring(0, 100)}`, true);
|
|
753
|
+
return new Promise((resolveResult) => {
|
|
754
|
+
let stdout = '';
|
|
755
|
+
let stderr = '';
|
|
756
|
+
let timedOut = false;
|
|
757
|
+
const child = spawn('docker', args, {
|
|
758
|
+
cwd: options?.cwd ?? this.projectDir,
|
|
759
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
760
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
761
|
+
});
|
|
762
|
+
const timer = setTimeout(() => {
|
|
763
|
+
timedOut = true;
|
|
764
|
+
child.kill('SIGKILL');
|
|
765
|
+
}, dockerTimeout);
|
|
766
|
+
child.stdout?.on('data', (data) => {
|
|
767
|
+
stdout += data.toString();
|
|
768
|
+
});
|
|
769
|
+
child.stderr?.on('data', (data) => {
|
|
770
|
+
stderr += data.toString();
|
|
771
|
+
});
|
|
772
|
+
if (options?.stdin) {
|
|
773
|
+
child.stdin?.write(options.stdin);
|
|
774
|
+
child.stdin?.end();
|
|
775
|
+
}
|
|
776
|
+
child.on('close', (exitCode) => {
|
|
777
|
+
clearTimeout(timer);
|
|
778
|
+
const duration = Date.now() - startTime;
|
|
779
|
+
resolveResult({
|
|
780
|
+
stdout: this.truncateOutput(stdout),
|
|
781
|
+
stderr: this.truncateOutput(stderr),
|
|
782
|
+
exitCode: timedOut ? 124 : (exitCode ?? 1),
|
|
783
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
784
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
785
|
+
duration,
|
|
786
|
+
timedOut,
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
child.on('error', (error) => {
|
|
790
|
+
clearTimeout(timer);
|
|
791
|
+
resolveResult({
|
|
792
|
+
stdout: '',
|
|
793
|
+
stderr: `Docker execution error: ${error.message}`,
|
|
794
|
+
exitCode: 1,
|
|
795
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
796
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
797
|
+
duration: Date.now() - startTime,
|
|
798
|
+
timedOut: false,
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
// ---- Native OS Execution ----
|
|
804
|
+
async executeNative(command, options, timeout, startTime) {
|
|
805
|
+
const currentPlatform = platform();
|
|
806
|
+
if (currentPlatform === 'linux') {
|
|
807
|
+
return this.executeLinuxSandbox(command, options, timeout, startTime);
|
|
808
|
+
}
|
|
809
|
+
if (currentPlatform === 'darwin') {
|
|
810
|
+
return this.executeMacOSSandbox(command, options, timeout, startTime);
|
|
811
|
+
}
|
|
812
|
+
// Unsupported platform, fall back to filtered execution
|
|
813
|
+
return this.executeFiltered(command, options, timeout, startTime);
|
|
814
|
+
}
|
|
815
|
+
async executeLinuxSandbox(command, options, timeout, startTime) {
|
|
816
|
+
const networkConfig = this.config.network;
|
|
817
|
+
let unshareFlags = '--pid --mount --uts --ipc';
|
|
818
|
+
// Network isolation
|
|
819
|
+
if (networkConfig.mode === 'blocked') {
|
|
820
|
+
unshareFlags += ' --net';
|
|
821
|
+
}
|
|
822
|
+
// Build the sandboxed command with namespace isolation
|
|
823
|
+
let sandboxedCommand = command;
|
|
824
|
+
// Apply filesystem restrictions via bind mounts where possible
|
|
825
|
+
const fsConfig = this.config.filesystem;
|
|
826
|
+
if (fsConfig && fsConfig.deniedPaths.length > 0) {
|
|
827
|
+
// Add a wrapper that checks path access
|
|
828
|
+
const deniedChecks = fsConfig.deniedPaths
|
|
829
|
+
.map(p => p.replace(/\*/g, '.*'))
|
|
830
|
+
.join('|');
|
|
831
|
+
sandboxedCommand = `bash -c '
|
|
832
|
+
deny_pattern="${deniedChecks}";
|
|
833
|
+
original_command="${command.replace(/"/g, '\\"').replace(/'/g, "'\\''")}";
|
|
834
|
+
$original_command
|
|
835
|
+
'`;
|
|
836
|
+
}
|
|
837
|
+
const fullCommand = `unshare ${unshareFlags} sh -c ${shellEscape(sandboxedCommand)}`;
|
|
838
|
+
this.addAuditLog('command', 'native_linux_execute', `Executing with namespaces: ${command.substring(0, 100)}`, true);
|
|
839
|
+
return new Promise((resolveResult) => {
|
|
840
|
+
let stdout = '';
|
|
841
|
+
let stderr = '';
|
|
842
|
+
let timedOut = false;
|
|
843
|
+
const child = spawn('sh', ['-c', fullCommand], {
|
|
844
|
+
cwd: options?.cwd ?? this.projectDir,
|
|
845
|
+
env: options?.env
|
|
846
|
+
? { ...process.env, ...this.commandFilter.sanitizeEnv(options.env) }
|
|
847
|
+
: { ...process.env, FORCE_COLOR: '0' },
|
|
848
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
849
|
+
});
|
|
850
|
+
const timer = setTimeout(() => {
|
|
851
|
+
timedOut = true;
|
|
852
|
+
child.kill('SIGKILL');
|
|
853
|
+
}, timeout);
|
|
854
|
+
child.stdout?.on('data', (data) => {
|
|
855
|
+
stdout += data.toString();
|
|
856
|
+
});
|
|
857
|
+
child.stderr?.on('data', (data) => {
|
|
858
|
+
stderr += data.toString();
|
|
859
|
+
});
|
|
860
|
+
if (options?.stdin) {
|
|
861
|
+
child.stdin?.write(options.stdin);
|
|
862
|
+
child.stdin?.end();
|
|
863
|
+
}
|
|
864
|
+
child.on('close', (exitCode) => {
|
|
865
|
+
clearTimeout(timer);
|
|
866
|
+
// If network was blocked, log any attempted network access via audit
|
|
867
|
+
if (networkConfig.mode === 'blocked') {
|
|
868
|
+
this.addAuditLog('network', 'namespace_isolation', 'Network namespace isolation applied (no external access)', true);
|
|
869
|
+
}
|
|
870
|
+
resolveResult({
|
|
871
|
+
stdout: this.truncateOutput(stdout),
|
|
872
|
+
stderr: this.truncateOutput(stderr),
|
|
873
|
+
exitCode: timedOut ? 124 : (exitCode ?? 1),
|
|
874
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
875
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
876
|
+
duration: Date.now() - startTime,
|
|
877
|
+
timedOut,
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
child.on('error', (error) => {
|
|
881
|
+
clearTimeout(timer);
|
|
882
|
+
resolveResult({
|
|
883
|
+
stdout: '',
|
|
884
|
+
stderr: `Native sandbox error: ${error.message}`,
|
|
885
|
+
exitCode: 1,
|
|
886
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
887
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
888
|
+
duration: Date.now() - startTime,
|
|
889
|
+
timedOut: false,
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
async executeMacOSSandbox(command, options, timeout, startTime) {
|
|
895
|
+
const networkConfig = this.config.network;
|
|
896
|
+
const fsConfig = this.config.filesystem;
|
|
897
|
+
// Build sandbox-exec profile
|
|
898
|
+
let profile = '(version 1)\n(deny default)\n';
|
|
899
|
+
// Allow basic process operations
|
|
900
|
+
profile += '(allow process-exec)\n';
|
|
901
|
+
profile += '(allow process-fork)\n';
|
|
902
|
+
profile += '(allow signal)\n';
|
|
903
|
+
profile += '(allow sysctl-read)\n';
|
|
904
|
+
profile += '(allow file-read*)\n';
|
|
905
|
+
// Filesystem write rules
|
|
906
|
+
if (fsConfig) {
|
|
907
|
+
// Allow writing to project directory
|
|
908
|
+
profile += `(allow file-write* (subpath "${this.projectDir}"))\n`;
|
|
909
|
+
// Allow writing to temp
|
|
910
|
+
profile += '(allow file-write* (subpath "/tmp"))\n';
|
|
911
|
+
profile += '(allow file-write* (subpath "/var/folders"))\n';
|
|
912
|
+
// Deny specific paths
|
|
913
|
+
for (const denied of fsConfig.deniedPaths) {
|
|
914
|
+
const expanded = denied.replace(/\/\*/g, '');
|
|
915
|
+
if (expanded.startsWith('/')) {
|
|
916
|
+
profile += `(deny file-read* (subpath "${expanded}"))\n`;
|
|
917
|
+
profile += `(deny file-write* (subpath "${expanded}"))\n`;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// Network rules
|
|
922
|
+
if (networkConfig.mode === 'blocked') {
|
|
923
|
+
profile += '(deny network*)\n';
|
|
924
|
+
}
|
|
925
|
+
else if (networkConfig.mode === 'filtered') {
|
|
926
|
+
profile += '(deny network*)\n';
|
|
927
|
+
for (const domain of networkConfig.allowedDomains) {
|
|
928
|
+
profile += `(allow network-outbound (host "${domain}"))\n`;
|
|
929
|
+
}
|
|
930
|
+
for (const port of networkConfig.allowedPorts) {
|
|
931
|
+
profile += `(allow network-outbound (port "${port}"))\n`;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
profile += '(allow network*)\n';
|
|
936
|
+
}
|
|
937
|
+
// Write profile to temp file
|
|
938
|
+
const profilePath = `/tmp/neurocli-sandbox-${Date.now()}.sb`;
|
|
939
|
+
writeFileSync(profilePath, profile, 'utf-8');
|
|
940
|
+
const sandboxedCommand = `sandbox-exec -f ${profilePath} sh -c ${shellEscape(command)}`;
|
|
941
|
+
this.addAuditLog('command', 'native_macos_execute', `Executing with sandbox-exec: ${command.substring(0, 100)}`, true);
|
|
942
|
+
return new Promise((resolveResult) => {
|
|
943
|
+
let stdout = '';
|
|
944
|
+
let stderr = '';
|
|
945
|
+
let timedOut = false;
|
|
946
|
+
const child = spawn('sh', ['-c', sandboxedCommand], {
|
|
947
|
+
cwd: options?.cwd ?? this.projectDir,
|
|
948
|
+
env: options?.env
|
|
949
|
+
? { ...process.env, ...this.commandFilter.sanitizeEnv(options.env) }
|
|
950
|
+
: { ...process.env, FORCE_COLOR: '0' },
|
|
951
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
952
|
+
});
|
|
953
|
+
const timer = setTimeout(() => {
|
|
954
|
+
timedOut = true;
|
|
955
|
+
child.kill('SIGKILL');
|
|
956
|
+
}, timeout);
|
|
957
|
+
child.stdout?.on('data', (data) => {
|
|
958
|
+
stdout += data.toString();
|
|
959
|
+
});
|
|
960
|
+
child.stderr?.on('data', (data) => {
|
|
961
|
+
stderr += data.toString();
|
|
962
|
+
});
|
|
963
|
+
if (options?.stdin) {
|
|
964
|
+
child.stdin?.write(options.stdin);
|
|
965
|
+
child.stdin?.end();
|
|
966
|
+
}
|
|
967
|
+
child.on('close', (exitCode) => {
|
|
968
|
+
clearTimeout(timer);
|
|
969
|
+
// Clean up profile file
|
|
970
|
+
try {
|
|
971
|
+
const { unlinkSync } = require('fs');
|
|
972
|
+
unlinkSync(profilePath);
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
// Best-effort cleanup
|
|
976
|
+
}
|
|
977
|
+
resolveResult({
|
|
978
|
+
stdout: this.truncateOutput(stdout),
|
|
979
|
+
stderr: this.truncateOutput(stderr),
|
|
980
|
+
exitCode: timedOut ? 124 : (exitCode ?? 1),
|
|
981
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
982
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
983
|
+
duration: Date.now() - startTime,
|
|
984
|
+
timedOut,
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
child.on('error', (error) => {
|
|
988
|
+
clearTimeout(timer);
|
|
989
|
+
resolveResult({
|
|
990
|
+
stdout: '',
|
|
991
|
+
stderr: `macOS sandbox error: ${error.message}`,
|
|
992
|
+
exitCode: 1,
|
|
993
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
994
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
995
|
+
duration: Date.now() - startTime,
|
|
996
|
+
timedOut: false,
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
// ---- Filtered Execution (Fallback, No OS Isolation) ----
|
|
1002
|
+
async executeFiltered(command, options, timeout, startTime) {
|
|
1003
|
+
this.addAuditLog('command', 'filtered_execute', `Executing with command filtering only (no OS isolation): ${command.substring(0, 100)}`, true);
|
|
1004
|
+
return new Promise((resolveResult) => {
|
|
1005
|
+
let stdout = '';
|
|
1006
|
+
let stderr = '';
|
|
1007
|
+
let timedOut = false;
|
|
1008
|
+
const child = spawn('sh', ['-c', command], {
|
|
1009
|
+
cwd: options?.cwd ?? this.projectDir,
|
|
1010
|
+
env: options?.env
|
|
1011
|
+
? { ...process.env, ...this.commandFilter.sanitizeEnv(options.env) }
|
|
1012
|
+
: { ...process.env, FORCE_COLOR: '0' },
|
|
1013
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1014
|
+
});
|
|
1015
|
+
const timer = setTimeout(() => {
|
|
1016
|
+
timedOut = true;
|
|
1017
|
+
child.kill('SIGKILL');
|
|
1018
|
+
}, timeout);
|
|
1019
|
+
child.stdout?.on('data', (data) => {
|
|
1020
|
+
stdout += data.toString();
|
|
1021
|
+
});
|
|
1022
|
+
child.stderr?.on('data', (data) => {
|
|
1023
|
+
stderr += data.toString();
|
|
1024
|
+
});
|
|
1025
|
+
if (options?.stdin) {
|
|
1026
|
+
child.stdin?.write(options.stdin);
|
|
1027
|
+
child.stdin?.end();
|
|
1028
|
+
}
|
|
1029
|
+
child.on('close', (exitCode) => {
|
|
1030
|
+
clearTimeout(timer);
|
|
1031
|
+
// Infer network access from command content for audit purposes
|
|
1032
|
+
if (command.includes('curl') || command.includes('wget') || command.includes('fetch')) {
|
|
1033
|
+
const urlMatch = command.match(/(?:https?:\/\/)([^/\s]+)/g);
|
|
1034
|
+
if (urlMatch) {
|
|
1035
|
+
for (const url of urlMatch) {
|
|
1036
|
+
const host = url.replace('https://', '').replace('http://', '').split('/')[0].split(':')[0];
|
|
1037
|
+
const port = url.includes(':443') ? 443 : url.includes(':80') ? 80 : url.startsWith('https') ? 443 : 80;
|
|
1038
|
+
this.networkPolicy.validate(host, port);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
resolveResult({
|
|
1043
|
+
stdout: this.truncateOutput(stdout),
|
|
1044
|
+
stderr: this.truncateOutput(stderr),
|
|
1045
|
+
exitCode: timedOut ? 124 : (exitCode ?? 1),
|
|
1046
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
1047
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
1048
|
+
duration: Date.now() - startTime,
|
|
1049
|
+
timedOut,
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
child.on('error', (error) => {
|
|
1053
|
+
clearTimeout(timer);
|
|
1054
|
+
resolveResult({
|
|
1055
|
+
stdout: '',
|
|
1056
|
+
stderr: `Filtered execution error: ${error.message}`,
|
|
1057
|
+
exitCode: 1,
|
|
1058
|
+
networkAccessLog: this.networkPolicy.getAccessLog(),
|
|
1059
|
+
fileAccessLog: this.filesystemPolicy.getAccessLog(),
|
|
1060
|
+
duration: Date.now() - startTime,
|
|
1061
|
+
timedOut: false,
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
// ---- Docker Image Management ----
|
|
1067
|
+
async ensureSandboxImage() {
|
|
1068
|
+
if (this.sandboxImageBuilt)
|
|
1069
|
+
return;
|
|
1070
|
+
const imageName = this.config.docker.image;
|
|
1071
|
+
// Check if image already exists
|
|
1072
|
+
try {
|
|
1073
|
+
execSync(`docker image inspect ${imageName} 2>/dev/null`, {
|
|
1074
|
+
encoding: 'utf-8',
|
|
1075
|
+
timeout: 10000,
|
|
1076
|
+
});
|
|
1077
|
+
this.sandboxImageBuilt = true;
|
|
1078
|
+
this.addAuditLog('container', 'image_exists', `Sandbox image ${imageName} already exists`, true);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
// Image doesn't exist, need to build it
|
|
1083
|
+
}
|
|
1084
|
+
// Create a temporary directory for the Dockerfile
|
|
1085
|
+
const buildDir = `/tmp/neurocli-sandbox-build-${Date.now()}`;
|
|
1086
|
+
try {
|
|
1087
|
+
mkdirSync(buildDir, { recursive: true });
|
|
1088
|
+
writeFileSync(`${buildDir}/Dockerfile`, SANDBOX_DOCKERFILE, 'utf-8');
|
|
1089
|
+
this.addAuditLog('container', 'build_image', `Building sandbox image ${imageName}...`, true);
|
|
1090
|
+
execSync(`docker build -t ${imageName} ${buildDir}`, {
|
|
1091
|
+
encoding: 'utf-8',
|
|
1092
|
+
timeout: 120000,
|
|
1093
|
+
});
|
|
1094
|
+
this.sandboxImageBuilt = true;
|
|
1095
|
+
this.addAuditLog('container', 'build_image_complete', `Sandbox image ${imageName} built successfully`, true);
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
this.addAuditLog('container', 'build_image_failed', `Failed to build sandbox image: ${error.message}`, false);
|
|
1099
|
+
throw new Error(`Failed to build sandbox Docker image: ${error.message}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// ---- Container Network Rules ----
|
|
1103
|
+
async applyContainerNetworkRules(containerId) {
|
|
1104
|
+
const dockerConfig = this.config.docker;
|
|
1105
|
+
if (dockerConfig.networkMode !== 'limited')
|
|
1106
|
+
return;
|
|
1107
|
+
try {
|
|
1108
|
+
// Get container PID for network namespace
|
|
1109
|
+
const pid = execSync(`docker inspect --format '{{.State.Pid}}' ${containerId} 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
1110
|
+
if (pid === '0') {
|
|
1111
|
+
this.addAuditLog('network', 'network_rules_skip', `Container ${containerId.substring(0, 12)} not running, skipping network rules`, true);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
// Set up iptables rules to restrict outbound traffic
|
|
1115
|
+
// Allow established connections
|
|
1116
|
+
execSync(`docker exec ${containerId} sh -c "` +
|
|
1117
|
+
`which iptables >/dev/null 2>&1 && ` +
|
|
1118
|
+
`iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT && ` +
|
|
1119
|
+
`iptables -A OUTPUT -o lo -j ACCEPT` +
|
|
1120
|
+
`" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 });
|
|
1121
|
+
// Allow DNS
|
|
1122
|
+
execSync(`docker exec ${containerId} sh -c "` +
|
|
1123
|
+
`which iptables >/dev/null 2>&1 && ` +
|
|
1124
|
+
`iptables -A OUTPUT -p udp --dport 53 -j ACCEPT` +
|
|
1125
|
+
`" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 });
|
|
1126
|
+
// Allow HTTPS to whitelisted domains (via DNS resolution)
|
|
1127
|
+
for (const domain of dockerConfig.allowedDomains) {
|
|
1128
|
+
try {
|
|
1129
|
+
execSync(`docker exec ${containerId} sh -c "` +
|
|
1130
|
+
`which iptables >/dev/null 2>&1 && ` +
|
|
1131
|
+
`iptables -A OUTPUT -p tcp --dport 443 -d $(nslookup ${domain} 2>/dev/null | tail -1 | awk '{print $2}') -j ACCEPT` +
|
|
1132
|
+
`" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 });
|
|
1133
|
+
}
|
|
1134
|
+
catch {
|
|
1135
|
+
// DNS resolution may fail, skip this domain
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Drop all other outbound
|
|
1139
|
+
execSync(`docker exec ${containerId} sh -c "` +
|
|
1140
|
+
`which iptables >/dev/null 2>&1 && ` +
|
|
1141
|
+
`iptables -A OUTPUT -j DROP` +
|
|
1142
|
+
`" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 });
|
|
1143
|
+
this.addAuditLog('network', 'network_rules_applied', `Network rules applied to container ${containerId.substring(0, 12)}`, true);
|
|
1144
|
+
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
this.addAuditLog('network', 'network_rules_failed', `Failed to apply network rules: ${error.message}`, false);
|
|
1147
|
+
// Non-fatal: container still has bridge network but without custom iptables
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
// ---- Utility Methods ----
|
|
1151
|
+
truncateOutput(output, maxLength = 30000) {
|
|
1152
|
+
if (output.length <= maxLength)
|
|
1153
|
+
return output;
|
|
1154
|
+
const half = Math.floor(maxLength / 2);
|
|
1155
|
+
return output.slice(0, half) + '\n\n... [output truncated] ...\n\n' + output.slice(-half);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// ---- Helper: Shell Escape ----
|
|
1159
|
+
function shellEscape(str) {
|
|
1160
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
1161
|
+
}
|
|
1162
|
+
// ---- Exported Helper: Create Default Manager ----
|
|
1163
|
+
export function createOSSandboxManager(config, projectDir) {
|
|
1164
|
+
const manager = new OSSandboxManager(config);
|
|
1165
|
+
if (projectDir) {
|
|
1166
|
+
manager.projectDir = resolve(projectDir);
|
|
1167
|
+
}
|
|
1168
|
+
return manager;
|
|
1169
|
+
}
|
|
1170
|
+
// ---- Exported Helper: Quick Availability Check ----
|
|
1171
|
+
export async function checkSandboxCapabilities() {
|
|
1172
|
+
const currentPlatform = platform();
|
|
1173
|
+
const manager = new OSSandboxManager();
|
|
1174
|
+
const docker = await manager.isDockerAvailable();
|
|
1175
|
+
const nativeSandbox = manager.isNativeSandboxAvailable();
|
|
1176
|
+
let recommendedType = 'docker';
|
|
1177
|
+
if (docker) {
|
|
1178
|
+
recommendedType = 'docker';
|
|
1179
|
+
}
|
|
1180
|
+
else if (nativeSandbox) {
|
|
1181
|
+
recommendedType = 'os-native';
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
recommendedType = 'hybrid';
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
docker,
|
|
1188
|
+
nativeSandbox,
|
|
1189
|
+
platform: currentPlatform,
|
|
1190
|
+
recommendedType,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
//# sourceMappingURL=os-sandbox.js.map
|