skimpyclaw 0.1.9 → 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/dist/__tests__/bash-path-validation.test.d.ts +1 -0
- package/dist/__tests__/bash-path-validation.test.js +164 -0
- package/dist/__tests__/doctor.runner.test.js +5 -1
- package/dist/__tests__/heartbeat.test.js +5 -5
- package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
- package/dist/__tests__/sandbox-bridge.test.js +116 -0
- package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
- package/dist/__tests__/sandbox-manager.test.js +119 -0
- package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
- package/dist/__tests__/sandbox-mount-security.test.js +131 -0
- package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
- package/dist/__tests__/sandbox-runtime.test.js +140 -0
- package/dist/__tests__/setup.test.js +28 -2
- package/dist/__tests__/skills.test.js +2 -11
- package/dist/__tests__/tools.test.js +6 -1
- package/dist/agent.js +2 -0
- package/dist/api.js +5 -1
- package/dist/channels/telegram/utils.js +2 -2
- package/dist/cli.js +212 -0
- package/dist/code-agents/executor.js +17 -4
- package/dist/code-agents/types.d.ts +5 -0
- package/dist/cron.js +16 -2
- package/dist/discord.js +2 -2
- package/dist/doctor/checks.d.ts +1 -0
- package/dist/doctor/checks.js +47 -0
- package/dist/doctor/runner.js +2 -1
- package/dist/exec-approval.d.ts +4 -0
- package/dist/exec-approval.js +4 -4
- package/dist/gateway.js +33 -2
- package/dist/heartbeat.js +3 -0
- package/dist/providers/openai.js +1 -1
- package/dist/sandbox/bridge.d.ts +5 -0
- package/dist/sandbox/bridge.js +63 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/manager.d.ts +7 -0
- package/dist/sandbox/manager.js +89 -0
- package/dist/sandbox/mount-security.d.ts +12 -0
- package/dist/sandbox/mount-security.js +118 -0
- package/dist/sandbox/runtime.d.ts +33 -0
- package/dist/sandbox/runtime.js +167 -0
- package/dist/service.js +17 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +335 -13
- package/dist/skills.d.ts +1 -2
- package/dist/skills.js +1 -13
- package/dist/tools/bash-path-validation.d.ts +22 -0
- package/dist/tools/bash-path-validation.js +130 -0
- package/dist/tools/bash-tool.js +23 -1
- package/dist/tools/definitions.d.ts +0 -7
- package/dist/tools/definitions.js +0 -5
- package/dist/tools/execute-context.d.ts +4 -0
- package/dist/tools/path-utils.js +16 -2
- package/dist/tools.js +84 -2
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/doctor/checks.js
CHANGED
|
@@ -341,6 +341,53 @@ export async function checkSkimpyclawDirWritable() {
|
|
|
341
341
|
return fail(name, category, `Directory not writable: ${base}`, 'Fix permissions for ~/.skimpyclaw.');
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
|
+
export async function checkSandboxAvailable(config) {
|
|
345
|
+
const name = 'sandbox_available';
|
|
346
|
+
const category = 'runtime';
|
|
347
|
+
if (!config.sandbox?.enabled) {
|
|
348
|
+
return ok(name, category, 'Sandbox disabled');
|
|
349
|
+
}
|
|
350
|
+
// Determine which runtime to check
|
|
351
|
+
const explicit = config.sandbox.runtime;
|
|
352
|
+
let rt = null;
|
|
353
|
+
if (explicit) {
|
|
354
|
+
const check = spawnSync(explicit, ['--version'], { encoding: 'utf-8' });
|
|
355
|
+
if (check.status !== 0) {
|
|
356
|
+
return fail(name, category, `${explicit} CLI not found`, `Install ${explicit} or change sandbox.runtime in config.`);
|
|
357
|
+
}
|
|
358
|
+
rt = explicit;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
// Auto-detect: prefer container, fall back to docker
|
|
362
|
+
const containerCheck = spawnSync('container', ['--version'], { encoding: 'utf-8' });
|
|
363
|
+
if (containerCheck.status === 0) {
|
|
364
|
+
rt = 'container';
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const dockerCheck = spawnSync('docker', ['--version'], { encoding: 'utf-8' });
|
|
368
|
+
if (dockerCheck.status === 0) {
|
|
369
|
+
rt = 'docker';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!rt) {
|
|
373
|
+
return fail(name, category, 'No container runtime found', 'Install Apple Containers or Docker, or disable sandbox in config.');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// For Apple Containers, check system is running
|
|
377
|
+
if (rt === 'container') {
|
|
378
|
+
const systemCheck = spawnSync('container', ['system', 'status'], { encoding: 'utf-8' });
|
|
379
|
+
if (systemCheck.status !== 0) {
|
|
380
|
+
return fail(name, category, 'Container system not running', 'Run "container system start" to start the container runtime.');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Check if sandbox image exists
|
|
384
|
+
const image = config.sandbox.image || 'skimpyclaw-sandbox';
|
|
385
|
+
const imageCheck = spawnSync(rt, ['image', 'inspect', image], { encoding: 'utf-8' });
|
|
386
|
+
if (imageCheck.status !== 0) {
|
|
387
|
+
return fail(name, category, `Sandbox image "${image}" not found`, `Build it: ${rt} build -t ${image} sandbox/`);
|
|
388
|
+
}
|
|
389
|
+
return ok(name, category, `Runtime: ${rt}, image "${image}" available`);
|
|
390
|
+
}
|
|
344
391
|
export async function checkPortAvailability(port) {
|
|
345
392
|
const name = 'gateway_port_available';
|
|
346
393
|
const category = 'runtime';
|
package/dist/doctor/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from '../config.js';
|
|
2
|
-
import { checkNodeVersion, checkPackageManagerAvailable, checkTypeScriptCompile, checkConfigExistsAndValidJson, checkRequiredEnvVars, checkEnvVarPatterns, checkAllowedPathsWritable, checkProviderAuth, checkTelegramToken, checkDiscordToken, checkBrowserBinaryIfEnabled, checkVoiceDependencies, checkMcpConfig, checkGatewayHostBindable, checkSkimpyclawDirWritable, checkPortAvailability, } from './checks.js';
|
|
2
|
+
import { checkNodeVersion, checkPackageManagerAvailable, checkTypeScriptCompile, checkConfigExistsAndValidJson, checkRequiredEnvVars, checkEnvVarPatterns, checkAllowedPathsWritable, checkProviderAuth, checkTelegramToken, checkDiscordToken, checkBrowserBinaryIfEnabled, checkVoiceDependencies, checkMcpConfig, checkGatewayHostBindable, checkSkimpyclawDirWritable, checkPortAvailability, checkSandboxAvailable, } from './checks.js';
|
|
3
3
|
export function computeExitCode(report) {
|
|
4
4
|
if (report.checks.some((check) => !check.ok && check.fatal)) {
|
|
5
5
|
return 2;
|
|
@@ -104,6 +104,7 @@ export async function runDoctor() {
|
|
|
104
104
|
checks.push(await runSafe('gateway_host_bindable', 'runtime', () => checkGatewayHostBindable(config.gateway.host ?? '127.0.0.1')));
|
|
105
105
|
checks.push(await runSafe('skimpyclaw_dirs_writable', 'runtime', () => checkSkimpyclawDirWritable()));
|
|
106
106
|
checks.push(await runSafe('gateway_port_available', 'runtime', () => checkPortAvailability(config.gateway.port)));
|
|
107
|
+
checks.push(await runSafe('sandbox_available', 'runtime', () => checkSandboxAvailable(config)));
|
|
107
108
|
const report = buildReport(startedAt, checks);
|
|
108
109
|
return { report, exitCode: report.exitCode };
|
|
109
110
|
}
|
package/dist/exec-approval.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export interface RiskClassification {
|
|
|
3
3
|
tier: RiskTier;
|
|
4
4
|
reason: string;
|
|
5
5
|
}
|
|
6
|
+
export declare function tokenizeShellCommand(command: string): string[];
|
|
7
|
+
export declare function getCommandSegments(command: string): string[][];
|
|
8
|
+
export declare function getSegmentCommandIndex(segment: string[]): number;
|
|
9
|
+
export declare function getExecutableName(token: string): string;
|
|
6
10
|
/**
|
|
7
11
|
* Classify the risk tier of a shell command.
|
|
8
12
|
* Returns the highest-tier match found.
|
package/dist/exec-approval.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Exec Approval Gate — risk classification and pending approval registry for Bash commands
|
|
2
2
|
import { randomUUID } from 'crypto';
|
|
3
3
|
import { EventEmitter } from 'events';
|
|
4
|
-
function tokenizeShellCommand(command) {
|
|
4
|
+
export function tokenizeShellCommand(command) {
|
|
5
5
|
const tokens = [];
|
|
6
6
|
let current = '';
|
|
7
7
|
let quote = null;
|
|
@@ -71,7 +71,7 @@ function tokenizeShellCommand(command) {
|
|
|
71
71
|
function isCommandSeparator(token) {
|
|
72
72
|
return token === '&&' || token === '||' || token === '|' || token === ';';
|
|
73
73
|
}
|
|
74
|
-
function getCommandSegments(command) {
|
|
74
|
+
export function getCommandSegments(command) {
|
|
75
75
|
const tokens = tokenizeShellCommand(command);
|
|
76
76
|
const segments = [];
|
|
77
77
|
let start = 0;
|
|
@@ -135,14 +135,14 @@ function isGitForcePushSegment(segment) {
|
|
|
135
135
|
}
|
|
136
136
|
return false;
|
|
137
137
|
}
|
|
138
|
-
function getSegmentCommandIndex(segment) {
|
|
138
|
+
export function getSegmentCommandIndex(segment) {
|
|
139
139
|
let idx = 0;
|
|
140
140
|
while (idx < segment.length && segment[idx].includes('=') && !segment[idx].startsWith('-')) {
|
|
141
141
|
idx++;
|
|
142
142
|
}
|
|
143
143
|
return idx;
|
|
144
144
|
}
|
|
145
|
-
function getExecutableName(token) {
|
|
145
|
+
export function getExecutableName(token) {
|
|
146
146
|
const normalized = token.toLowerCase();
|
|
147
147
|
const slash = normalized.lastIndexOf('/');
|
|
148
148
|
return slash >= 0 ? normalized.slice(slash + 1) : normalized;
|
package/dist/gateway.js
CHANGED
|
@@ -3,6 +3,7 @@ import Fastify from 'fastify';
|
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { join } from 'path';
|
|
6
|
+
import { timingSafeEqual } from 'crypto';
|
|
6
7
|
import { runAgentTurn } from './agent.js';
|
|
7
8
|
import { getCronJobs, runCronJob } from './cron.js';
|
|
8
9
|
import { registerDashboardAPI } from './api.js';
|
|
@@ -32,6 +33,15 @@ export async function createGateway(cfg) {
|
|
|
32
33
|
level: 'info',
|
|
33
34
|
},
|
|
34
35
|
});
|
|
36
|
+
// Block cross-origin requests — deny all CORS preflight and tag responses
|
|
37
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
38
|
+
if (request.method === 'OPTIONS') {
|
|
39
|
+
return reply.code(403).send({ error: 'CORS not allowed' });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
fastify.addHook('onSend', async (_request, reply) => {
|
|
43
|
+
reply.header('Access-Control-Allow-Origin', 'null'); // deny all origins
|
|
44
|
+
});
|
|
35
45
|
// Health check
|
|
36
46
|
fastify.get('/health', async () => {
|
|
37
47
|
return { status: 'ok', uptime: Date.now() - startTime.getTime() };
|
|
@@ -96,10 +106,31 @@ export async function createGateway(cfg) {
|
|
|
96
106
|
fastify.post('/reload', async () => {
|
|
97
107
|
return { status: 'ok', note: 'Restart required for config changes' };
|
|
98
108
|
});
|
|
99
|
-
// Ensure dashboard token exists
|
|
109
|
+
// Ensure dashboard token exists
|
|
100
110
|
const dashboardToken = ensureDashboardToken(config);
|
|
101
|
-
console.log(`[dashboard] Access token: ${dashboardToken}`);
|
|
102
111
|
console.log(`[dashboard] URL: http://localhost:${config.gateway.port}/dashboard`);
|
|
112
|
+
// Auth guard for gateway write endpoints (same token as dashboard)
|
|
113
|
+
const PROTECTED_ROUTES = new Set(['/message', '/model', '/reload']);
|
|
114
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
115
|
+
const url = request.url;
|
|
116
|
+
// Protect write endpoints + cron trigger
|
|
117
|
+
if (!PROTECTED_ROUTES.has(url) && !url.startsWith('/cron/'))
|
|
118
|
+
return;
|
|
119
|
+
if (request.method === 'GET')
|
|
120
|
+
return; // GET /health, GET /status are fine
|
|
121
|
+
if (!dashboardToken)
|
|
122
|
+
return; // No token configured, allow access
|
|
123
|
+
const authHeader = request.headers.authorization;
|
|
124
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
125
|
+
return reply.code(401).send({ error: 'Unauthorized: Bearer token required' });
|
|
126
|
+
}
|
|
127
|
+
const provided = authHeader.slice(7);
|
|
128
|
+
const tokenBuf = Buffer.from(dashboardToken, 'utf8');
|
|
129
|
+
const providedBuf = Buffer.from(provided, 'utf8');
|
|
130
|
+
if (tokenBuf.length !== providedBuf.length || !timingSafeEqual(tokenBuf, providedBuf)) {
|
|
131
|
+
return reply.code(401).send({ error: 'Unauthorized: Invalid token' });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
103
134
|
// Register dashboard API routes (includes auth hook)
|
|
104
135
|
registerDashboardAPI(fastify, config);
|
|
105
136
|
// Register dashboard frontend (framework app)
|
package/dist/heartbeat.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { runAgentTurn } from './agent.js';
|
|
9
|
+
import { pruneIdle, SANDBOX_DEFAULTS } from './sandbox/index.js';
|
|
9
10
|
import { getActiveChannelId, isActiveChannelSilenced, sendActiveChannelProactiveMessage, } from './channels.js';
|
|
10
11
|
let heartbeatTimer = null;
|
|
11
12
|
let running = false;
|
|
@@ -78,6 +79,8 @@ export function stopHeartbeat() {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
export async function runHeartbeatCheck(config) {
|
|
82
|
+
// Prune idle sandbox containers
|
|
83
|
+
pruneIdle(config.sandbox?.idleTimeoutMs ?? SANDBOX_DEFAULTS.idleTimeoutMs ?? 3_600_000).catch(() => { });
|
|
81
84
|
if (running) {
|
|
82
85
|
console.log('[heartbeat] Skipping — previous check still running');
|
|
83
86
|
return 'Skipped — previous check still running';
|
package/dist/providers/openai.js
CHANGED
|
@@ -33,7 +33,7 @@ function recordOpenAIUsage(params) {
|
|
|
33
33
|
let inputTokens = typeof usage?.prompt_tokens === 'number'
|
|
34
34
|
? usage.prompt_tokens
|
|
35
35
|
: (typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0);
|
|
36
|
-
|
|
36
|
+
const outputTokens = typeof usage?.completion_tokens === 'number'
|
|
37
37
|
? usage.completion_tokens
|
|
38
38
|
: (typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0);
|
|
39
39
|
// Some OpenAI-compatible providers only return total_tokens.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function sandboxBash(containerName: string, command: string, cwd?: string, timeout?: number): Promise<string>;
|
|
2
|
+
export declare function sandboxReadFile(containerName: string, path: string): Promise<string>;
|
|
3
|
+
export declare function sandboxWriteFile(containerName: string, path: string, content: string): Promise<string>;
|
|
4
|
+
export declare function sandboxListDir(containerName: string, path: string): Promise<string>;
|
|
5
|
+
export declare function sandboxGlob(containerName: string, base: string, pattern: string): Promise<string>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execInContainer } from './runtime.js';
|
|
2
|
+
const MAX_BASH_OUTPUT = 50 * 1024; // 50KB
|
|
3
|
+
const MAX_READ_OUTPUT = 100 * 1024; // 100KB
|
|
4
|
+
function truncate(s, maxBytes) {
|
|
5
|
+
if (Buffer.byteLength(s) <= maxBytes)
|
|
6
|
+
return s;
|
|
7
|
+
const truncated = Buffer.from(s).subarray(0, maxBytes).toString('utf-8');
|
|
8
|
+
return truncated + '\n... (output truncated)';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Shell-escape a string for use inside sh -c '...'
|
|
12
|
+
* NOTE: execInContainer already wraps in sh -c, so callers pass
|
|
13
|
+
* args as individual tokens. The runtime joins them with spaces
|
|
14
|
+
* and does basic single-quote escaping. For bash commands we pass
|
|
15
|
+
* the whole command as a single arg string.
|
|
16
|
+
*/
|
|
17
|
+
function shellEscape(s) {
|
|
18
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
19
|
+
}
|
|
20
|
+
export async function sandboxBash(containerName, command, cwd, timeout) {
|
|
21
|
+
// Pass the full command as a single string so sh -c runs it verbatim
|
|
22
|
+
const shellCmd = cwd
|
|
23
|
+
? `cd ${shellEscape(cwd)} && ${command}`
|
|
24
|
+
: command;
|
|
25
|
+
const result = await execInContainer(containerName, [shellCmd], { timeout: timeout ?? 30_000 });
|
|
26
|
+
let output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
27
|
+
output = truncate(output, MAX_BASH_OUTPUT);
|
|
28
|
+
if (result.exitCode !== 0) {
|
|
29
|
+
output += `\n[exit code: ${result.exitCode}]`;
|
|
30
|
+
}
|
|
31
|
+
return output || '(no output)';
|
|
32
|
+
}
|
|
33
|
+
export async function sandboxReadFile(containerName, path) {
|
|
34
|
+
// Pass as a single command string to avoid double-escaping
|
|
35
|
+
const result = await execInContainer(containerName, [`cat -- ${shellEscape(path)}`]);
|
|
36
|
+
if (result.exitCode !== 0) {
|
|
37
|
+
throw new Error(`Failed to read ${path}: ${result.stderr}`);
|
|
38
|
+
}
|
|
39
|
+
return truncate(result.stdout, MAX_READ_OUTPUT);
|
|
40
|
+
}
|
|
41
|
+
export async function sandboxWriteFile(containerName, path, content) {
|
|
42
|
+
// mkdir -p for parent dir, then write via stdin
|
|
43
|
+
const result = await execInContainer(containerName, [`mkdir -p $(dirname ${shellEscape(path)}) && cat > ${shellEscape(path)}`], { stdin: content });
|
|
44
|
+
if (result.exitCode !== 0) {
|
|
45
|
+
throw new Error(`Failed to write ${path}: ${result.stderr}`);
|
|
46
|
+
}
|
|
47
|
+
const bytes = Buffer.byteLength(content);
|
|
48
|
+
return `Written: ${path} (${bytes} bytes)`;
|
|
49
|
+
}
|
|
50
|
+
export async function sandboxListDir(containerName, path) {
|
|
51
|
+
const result = await execInContainer(containerName, [`ls -la ${shellEscape(path)}`]);
|
|
52
|
+
if (result.exitCode !== 0) {
|
|
53
|
+
throw new Error(`Failed to list ${path}: ${result.stderr}`);
|
|
54
|
+
}
|
|
55
|
+
return result.stdout;
|
|
56
|
+
}
|
|
57
|
+
export async function sandboxGlob(containerName, base, pattern) {
|
|
58
|
+
const result = await execInContainer(containerName, [`find ${shellEscape(base)} -name ${shellEscape(pattern)} -maxdepth 10 -type f`]);
|
|
59
|
+
if (result.exitCode !== 0) {
|
|
60
|
+
throw new Error(`Failed to glob ${base}/${pattern}: ${result.stderr}`);
|
|
61
|
+
}
|
|
62
|
+
return result.stdout;
|
|
63
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime } from './runtime.js';
|
|
2
|
+
export type { ContainerOpts, ExecOpts, ExecResult } from './runtime.js';
|
|
3
|
+
export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
|
|
4
|
+
export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
|
|
5
|
+
export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime } from './runtime.js';
|
|
2
|
+
export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
|
|
3
|
+
export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
|
|
4
|
+
export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SandboxConfig } from '../types.js';
|
|
2
|
+
export declare const SANDBOX_DEFAULTS: SandboxConfig;
|
|
3
|
+
export declare function ensureContainer(sessionId: string, config: SandboxConfig, allowedPaths: string[]): Promise<string>;
|
|
4
|
+
export declare function releaseContainer(sessionId: string): Promise<void>;
|
|
5
|
+
export declare function pruneIdle(maxIdleMs: number): Promise<number>;
|
|
6
|
+
export declare function releaseAll(): Promise<void>;
|
|
7
|
+
export declare function resetForTesting(): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createContainer, removeContainer, isContainerRunning } from './runtime.js';
|
|
2
|
+
import { validateMountPaths } from './mount-security.js';
|
|
3
|
+
export const SANDBOX_DEFAULTS = {
|
|
4
|
+
enabled: false,
|
|
5
|
+
image: 'skimpyclaw-sandbox',
|
|
6
|
+
cpus: 2,
|
|
7
|
+
memory: '2G',
|
|
8
|
+
network: 'bridge',
|
|
9
|
+
idleTimeoutMs: 3_600_000,
|
|
10
|
+
};
|
|
11
|
+
const activeContainers = new Map();
|
|
12
|
+
export async function ensureContainer(sessionId, config, allowedPaths) {
|
|
13
|
+
const name = `skimpyclaw-sbx-${sessionId}`;
|
|
14
|
+
const existing = activeContainers.get(sessionId);
|
|
15
|
+
if (existing) {
|
|
16
|
+
const running = await isContainerRunning(existing.name);
|
|
17
|
+
if (running) {
|
|
18
|
+
existing.lastUsed = Date.now();
|
|
19
|
+
return existing.name;
|
|
20
|
+
}
|
|
21
|
+
// Container died — remove from map, recreate
|
|
22
|
+
activeContainers.delete(sessionId);
|
|
23
|
+
}
|
|
24
|
+
// Process restarts clear in-memory state; adopt or clean an existing named
|
|
25
|
+
// container so a duplicate-name create does not fail.
|
|
26
|
+
const alreadyRunning = await isContainerRunning(name);
|
|
27
|
+
if (alreadyRunning) {
|
|
28
|
+
activeContainers.set(sessionId, {
|
|
29
|
+
name,
|
|
30
|
+
sessionId,
|
|
31
|
+
lastUsed: Date.now(),
|
|
32
|
+
});
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
// Best effort cleanup for stopped or half-created containers with same name.
|
|
36
|
+
await removeContainer(name);
|
|
37
|
+
const mounts = validateMountPaths(allowedPaths);
|
|
38
|
+
const uid = process.getuid?.() ?? 501;
|
|
39
|
+
const gid = process.getgid?.() ?? 20;
|
|
40
|
+
const merged = { ...SANDBOX_DEFAULTS, ...config };
|
|
41
|
+
const opts = {
|
|
42
|
+
image: merged.image,
|
|
43
|
+
cpus: merged.cpus,
|
|
44
|
+
memory: merged.memory,
|
|
45
|
+
network: merged.network,
|
|
46
|
+
mounts: mounts.map((m) => ({
|
|
47
|
+
host: m.host,
|
|
48
|
+
container: m.container,
|
|
49
|
+
readOnly: m.readOnly,
|
|
50
|
+
})),
|
|
51
|
+
user: `${uid}:${gid}`,
|
|
52
|
+
};
|
|
53
|
+
await createContainer(name, opts);
|
|
54
|
+
activeContainers.set(sessionId, {
|
|
55
|
+
name,
|
|
56
|
+
sessionId,
|
|
57
|
+
lastUsed: Date.now(),
|
|
58
|
+
});
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
export async function releaseContainer(sessionId) {
|
|
62
|
+
const entry = activeContainers.get(sessionId);
|
|
63
|
+
if (!entry)
|
|
64
|
+
return;
|
|
65
|
+
activeContainers.delete(sessionId);
|
|
66
|
+
await removeContainer(entry.name);
|
|
67
|
+
}
|
|
68
|
+
export async function pruneIdle(maxIdleMs) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
let pruned = 0;
|
|
71
|
+
for (const [sessionId, entry] of activeContainers) {
|
|
72
|
+
if (now - entry.lastUsed > maxIdleMs) {
|
|
73
|
+
activeContainers.delete(sessionId);
|
|
74
|
+
await removeContainer(entry.name);
|
|
75
|
+
pruned++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return pruned;
|
|
79
|
+
}
|
|
80
|
+
export async function releaseAll() {
|
|
81
|
+
const entries = Array.from(activeContainers.values());
|
|
82
|
+
activeContainers.clear();
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
await removeContainer(entry.name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function resetForTesting() {
|
|
88
|
+
activeContainers.clear();
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface MountSpec {
|
|
2
|
+
host: string;
|
|
3
|
+
container: string;
|
|
4
|
+
readOnly: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function isBlockedPath(resolvedPath: string): boolean;
|
|
7
|
+
export declare function validateMountPaths(allowedPaths: string[]): MountSpec[];
|
|
8
|
+
/**
|
|
9
|
+
* Translate a host path to its container equivalent using mount specs.
|
|
10
|
+
* Returns the original path if no mount matches (will likely fail inside container).
|
|
11
|
+
*/
|
|
12
|
+
export declare function translatePath(hostPath: string, mounts: MountSpec[]): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { realpathSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve, basename } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
const BLOCKED_PATTERNS = [
|
|
5
|
+
'.ssh',
|
|
6
|
+
'.gnupg',
|
|
7
|
+
'.gpg',
|
|
8
|
+
'.aws',
|
|
9
|
+
'.azure',
|
|
10
|
+
'.gcloud',
|
|
11
|
+
'credentials',
|
|
12
|
+
'.env',
|
|
13
|
+
'.npmrc',
|
|
14
|
+
'.pypirc',
|
|
15
|
+
'.docker/config.json',
|
|
16
|
+
'.kube',
|
|
17
|
+
];
|
|
18
|
+
export function isBlockedPath(resolvedPath) {
|
|
19
|
+
const segments = resolvedPath.split('/');
|
|
20
|
+
for (const segment of segments) {
|
|
21
|
+
if (BLOCKED_PATTERNS.includes(segment)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Also check for compound patterns like .docker/config.json
|
|
26
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
27
|
+
if (pattern.includes('/') && resolvedPath.includes(pattern)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
export function validateMountPaths(allowedPaths) {
|
|
34
|
+
const home = homedir();
|
|
35
|
+
const mounts = [];
|
|
36
|
+
const usedBaseNames = new Map();
|
|
37
|
+
for (const rawPath of allowedPaths) {
|
|
38
|
+
const expanded = rawPath.startsWith('~')
|
|
39
|
+
? resolve(home, rawPath.slice(2))
|
|
40
|
+
: resolve(rawPath);
|
|
41
|
+
let resolved;
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(expanded)) {
|
|
44
|
+
continue; // Skip missing paths
|
|
45
|
+
}
|
|
46
|
+
resolved = realpathSync(expanded);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue; // Skip paths that can't be resolved
|
|
50
|
+
}
|
|
51
|
+
if (isBlockedPath(resolved)) {
|
|
52
|
+
throw new Error(`Blocked path: ${resolved} matches security exclusion pattern`);
|
|
53
|
+
}
|
|
54
|
+
let base = basename(resolved);
|
|
55
|
+
const count = usedBaseNames.get(base) ?? 0;
|
|
56
|
+
usedBaseNames.set(base, count + 1);
|
|
57
|
+
if (count > 0) {
|
|
58
|
+
base = `${base}_${count}`;
|
|
59
|
+
}
|
|
60
|
+
mounts.push({
|
|
61
|
+
host: resolved,
|
|
62
|
+
container: `/workspace/${base}`,
|
|
63
|
+
readOnly: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Always add ~/.skimpyclaw at /workspace/config
|
|
67
|
+
const skimpyclawDir = resolve(home, '.skimpyclaw');
|
|
68
|
+
if (existsSync(skimpyclawDir)) {
|
|
69
|
+
const resolved = realpathSync(skimpyclawDir);
|
|
70
|
+
// Only add if not already included
|
|
71
|
+
if (!mounts.some((m) => m.host === resolved)) {
|
|
72
|
+
mounts.push({
|
|
73
|
+
host: resolved,
|
|
74
|
+
container: '/workspace/config',
|
|
75
|
+
readOnly: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return mounts;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Translate a host path to its container equivalent using mount specs.
|
|
83
|
+
* Returns the original path if no mount matches (will likely fail inside container).
|
|
84
|
+
*/
|
|
85
|
+
export function translatePath(hostPath, mounts) {
|
|
86
|
+
const candidates = getPathCandidates(hostPath);
|
|
87
|
+
// Sort by host path length descending so we match the most specific mount first
|
|
88
|
+
const sorted = [...mounts].sort((a, b) => b.host.length - a.host.length);
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
for (const mount of sorted) {
|
|
91
|
+
if (candidate === mount.host || candidate.startsWith(mount.host + '/')) {
|
|
92
|
+
const relative = candidate.slice(mount.host.length);
|
|
93
|
+
return mount.container + relative;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return hostPath;
|
|
98
|
+
}
|
|
99
|
+
function getPathCandidates(hostPath) {
|
|
100
|
+
const set = new Set();
|
|
101
|
+
const resolved = resolve(hostPath);
|
|
102
|
+
set.add(resolved);
|
|
103
|
+
try {
|
|
104
|
+
set.add(realpathSync(resolved));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Path may not exist yet (e.g. write targets); keep resolved form.
|
|
108
|
+
}
|
|
109
|
+
for (const p of Array.from(set)) {
|
|
110
|
+
if (p.startsWith('/Users/')) {
|
|
111
|
+
set.add(`/System/Volumes/Data${p}`);
|
|
112
|
+
}
|
|
113
|
+
else if (p.startsWith('/System/Volumes/Data/Users/')) {
|
|
114
|
+
set.add(p.replace('/System/Volumes/Data', ''));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Array.from(set);
|
|
118
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ContainerOpts {
|
|
2
|
+
image: string;
|
|
3
|
+
cpus?: number;
|
|
4
|
+
memory?: string;
|
|
5
|
+
network?: string;
|
|
6
|
+
mounts?: Array<{
|
|
7
|
+
host: string;
|
|
8
|
+
container: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
user?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ExecOpts {
|
|
14
|
+
stdin?: string;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
export interface ExecResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
/** Set the container runtime binary ('container' or 'docker'). */
|
|
24
|
+
export declare function setRuntime(runtime: 'container' | 'docker'): void;
|
|
25
|
+
/** Get the container runtime binary, auto-detecting if not explicitly set. */
|
|
26
|
+
export declare function getRuntime(): string;
|
|
27
|
+
/** Reset runtime detection (for testing). */
|
|
28
|
+
export declare function resetRuntime(): void;
|
|
29
|
+
export declare function createContainer(name: string, opts: ContainerOpts): Promise<void>;
|
|
30
|
+
export declare function execInContainer(name: string, args: string[], opts?: ExecOpts): Promise<ExecResult>;
|
|
31
|
+
export declare function removeContainer(name: string): Promise<void>;
|
|
32
|
+
export declare function isContainerRunning(name: string): Promise<boolean>;
|
|
33
|
+
export declare function cleanupOrphans(): Promise<number>;
|