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.
Files changed (56) hide show
  1. package/dist/__tests__/bash-path-validation.test.d.ts +1 -0
  2. package/dist/__tests__/bash-path-validation.test.js +164 -0
  3. package/dist/__tests__/doctor.runner.test.js +5 -1
  4. package/dist/__tests__/heartbeat.test.js +5 -5
  5. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  6. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  7. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  8. package/dist/__tests__/sandbox-manager.test.js +119 -0
  9. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  10. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  11. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  12. package/dist/__tests__/sandbox-runtime.test.js +140 -0
  13. package/dist/__tests__/setup.test.js +28 -2
  14. package/dist/__tests__/skills.test.js +2 -11
  15. package/dist/__tests__/tools.test.js +6 -1
  16. package/dist/agent.js +2 -0
  17. package/dist/api.js +5 -1
  18. package/dist/channels/telegram/utils.js +2 -2
  19. package/dist/cli.js +212 -0
  20. package/dist/code-agents/executor.js +17 -4
  21. package/dist/code-agents/types.d.ts +5 -0
  22. package/dist/cron.js +16 -2
  23. package/dist/discord.js +2 -2
  24. package/dist/doctor/checks.d.ts +1 -0
  25. package/dist/doctor/checks.js +47 -0
  26. package/dist/doctor/runner.js +2 -1
  27. package/dist/exec-approval.d.ts +4 -0
  28. package/dist/exec-approval.js +4 -4
  29. package/dist/gateway.js +33 -2
  30. package/dist/heartbeat.js +3 -0
  31. package/dist/providers/openai.js +1 -1
  32. package/dist/sandbox/bridge.d.ts +5 -0
  33. package/dist/sandbox/bridge.js +63 -0
  34. package/dist/sandbox/index.d.ts +5 -0
  35. package/dist/sandbox/index.js +4 -0
  36. package/dist/sandbox/manager.d.ts +7 -0
  37. package/dist/sandbox/manager.js +89 -0
  38. package/dist/sandbox/mount-security.d.ts +12 -0
  39. package/dist/sandbox/mount-security.js +118 -0
  40. package/dist/sandbox/runtime.d.ts +33 -0
  41. package/dist/sandbox/runtime.js +167 -0
  42. package/dist/service.js +17 -0
  43. package/dist/setup.d.ts +11 -0
  44. package/dist/setup.js +335 -13
  45. package/dist/skills.d.ts +1 -2
  46. package/dist/skills.js +1 -13
  47. package/dist/tools/bash-path-validation.d.ts +22 -0
  48. package/dist/tools/bash-path-validation.js +130 -0
  49. package/dist/tools/bash-tool.js +23 -1
  50. package/dist/tools/definitions.d.ts +0 -7
  51. package/dist/tools/definitions.js +0 -5
  52. package/dist/tools/execute-context.d.ts +4 -0
  53. package/dist/tools/path-utils.js +16 -2
  54. package/dist/tools.js +84 -2
  55. package/dist/types.d.ts +10 -0
  56. package/package.json +1 -1
@@ -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';
@@ -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
  }
@@ -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.
@@ -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 and print it
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';
@@ -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
- let outputTokens = typeof usage?.completion_tokens === 'number'
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>;