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/tools/bash-tool.js
CHANGED
|
@@ -2,6 +2,23 @@ import { exec } from 'child_process';
|
|
|
2
2
|
import { isBashCommandSafe } from '../security.js';
|
|
3
3
|
import { classifyCommandRisk, requiresApproval, createApprovalRequest, waitForApproval, } from '../exec-approval.js';
|
|
4
4
|
import { isPathAllowed } from './path-utils.js';
|
|
5
|
+
import { validateBashPaths } from './bash-path-validation.js';
|
|
6
|
+
/** Env var name patterns that should never be exposed to model-executed commands. */
|
|
7
|
+
const SENSITIVE_ENV_PATTERNS = [
|
|
8
|
+
/api.?key/i, /token/i, /secret/i, /password/i, /credential/i,
|
|
9
|
+
/^ANTHROPIC_/i, /^OPENAI_/i, /^CLAUDE/i, /^CODEX_/i, /^MINIMAX_/i,
|
|
10
|
+
/^KIMI_/i, /^TOGETHER_/i, /^GROQ_/i, /^OPENROUTER_/i,
|
|
11
|
+
];
|
|
12
|
+
/** Create a sanitized copy of process.env with secrets stripped. */
|
|
13
|
+
function sanitizeEnv() {
|
|
14
|
+
const env = { ...process.env };
|
|
15
|
+
for (const key of Object.keys(env)) {
|
|
16
|
+
if (SENSITIVE_ENV_PATTERNS.some(p => p.test(key))) {
|
|
17
|
+
delete env[key];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
21
|
+
}
|
|
5
22
|
export async function executeBash(command, cwd, config, context) {
|
|
6
23
|
// Hard block: existing safety filter (always enforced)
|
|
7
24
|
if (!isBashCommandSafe(command)) {
|
|
@@ -10,6 +27,11 @@ export async function executeBash(command, cwd, config, context) {
|
|
|
10
27
|
if (cwd && !isPathAllowed(cwd, config.allowedPaths)) {
|
|
11
28
|
return Promise.resolve('Error: Working directory not in allowed paths.');
|
|
12
29
|
}
|
|
30
|
+
// Validate file paths referenced in command arguments
|
|
31
|
+
const pathError = validateBashPaths(command, cwd, config.allowedPaths);
|
|
32
|
+
if (pathError) {
|
|
33
|
+
return Promise.resolve(pathError);
|
|
34
|
+
}
|
|
13
35
|
// Exec approval gate: classify risk and check if approval is needed
|
|
14
36
|
const approvalConfig = config.execApproval;
|
|
15
37
|
if (approvalConfig?.enabled !== false) {
|
|
@@ -44,7 +66,7 @@ export async function executeBash(command, cwd, config, context) {
|
|
|
44
66
|
exec(command, {
|
|
45
67
|
cwd: cwd || undefined,
|
|
46
68
|
timeout,
|
|
47
|
-
env:
|
|
69
|
+
env: sanitizeEnv(),
|
|
48
70
|
maxBuffer: 5 * 1024 * 1024,
|
|
49
71
|
}, (error, stdout, stderr) => {
|
|
50
72
|
if (error) {
|
|
@@ -126,11 +126,6 @@ export const SPAWN_SUBAGENT_TOOL = {
|
|
|
126
126
|
},
|
|
127
127
|
model: { type: 'string', description: 'Optional model override (e.g. claude-opus, claude-think)' },
|
|
128
128
|
label: { type: 'string', description: 'Short label for status display (e.g. "write tests", "check logs")' },
|
|
129
|
-
allowedPaths: {
|
|
130
|
-
type: 'array',
|
|
131
|
-
items: { type: 'string' },
|
|
132
|
-
description: 'Additional file paths the subagent can access beyond defaults',
|
|
133
|
-
},
|
|
134
129
|
},
|
|
135
130
|
required: ['task', 'type'],
|
|
136
131
|
},
|
|
@@ -23,4 +23,8 @@ export interface ExecuteToolContext {
|
|
|
23
23
|
trigger?: string;
|
|
24
24
|
/** Agent ID for usage tracking */
|
|
25
25
|
agentId?: string;
|
|
26
|
+
/** Sandbox configuration for containerized tool execution */
|
|
27
|
+
sandboxConfig?: import('../types.js').SandboxConfig;
|
|
28
|
+
/** Session ID for sandbox container mapping */
|
|
29
|
+
sessionId?: string;
|
|
26
30
|
}
|
package/dist/tools/path-utils.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { resolve, sep } from 'path';
|
|
2
|
+
import { realpathSync } from 'fs';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a path to its real location, following symlinks.
|
|
5
|
+
* Falls back to path.resolve() if the path doesn't exist yet (e.g. for writes).
|
|
6
|
+
*/
|
|
7
|
+
function safeRealpath(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(filePath);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Path doesn't exist yet — resolve logically (normalizes .. but can't follow symlinks)
|
|
13
|
+
return resolve(filePath);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
2
16
|
export function isPathAllowed(filePath, allowedPaths) {
|
|
3
|
-
const resolved =
|
|
17
|
+
const resolved = safeRealpath(filePath);
|
|
4
18
|
return allowedPaths.some((allowed) => {
|
|
5
|
-
const allowedRoot =
|
|
19
|
+
const allowedRoot = safeRealpath(allowed);
|
|
6
20
|
return resolved === allowedRoot || resolved.startsWith(`${allowedRoot}${sep}`);
|
|
7
21
|
});
|
|
8
22
|
}
|
package/dist/tools.js
CHANGED
|
@@ -6,6 +6,8 @@ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER
|
|
|
6
6
|
import { executeReadFile, executeWriteFileLocked, executeListDirectory } from './tools/file-tools.js';
|
|
7
7
|
import { executeBash } from './tools/bash-tool.js';
|
|
8
8
|
import { executeBrowser, cleanupBrowser } from './tools/browser-tool.js';
|
|
9
|
+
import { ensureContainer, SANDBOX_DEFAULTS, translatePath, validateMountPaths } from './sandbox/index.js';
|
|
10
|
+
import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './sandbox/index.js';
|
|
9
11
|
// Re-export from code-agents module for backward compatibility
|
|
10
12
|
export {
|
|
11
13
|
// Registry functions
|
|
@@ -240,7 +242,23 @@ async function executeWebSearch(query) {
|
|
|
240
242
|
export async function executeTool(name, input, config, context) {
|
|
241
243
|
try {
|
|
242
244
|
// Route MCP tools BEFORE normalization to preserve server/tool name casing
|
|
245
|
+
// NOTE: MCP servers run as external processes with full host access.
|
|
246
|
+
// We validate path-like arguments as a best-effort check, but MCP servers
|
|
247
|
+
// are a trusted boundary — only configure servers you trust.
|
|
243
248
|
if (name.startsWith('mcp__')) {
|
|
249
|
+
if (config.allowedPaths?.length) {
|
|
250
|
+
const { isPathAllowed } = await import('./tools/path-utils.js');
|
|
251
|
+
for (const [key, value] of Object.entries(input)) {
|
|
252
|
+
if (typeof value === 'string' && (value.startsWith('/') || value.startsWith('~/') || value.startsWith('./'))) {
|
|
253
|
+
const { resolve } = await import('path');
|
|
254
|
+
const { homedir } = await import('os');
|
|
255
|
+
const resolved = value.startsWith('~/') ? resolve(homedir(), value.slice(2)) : resolve(value);
|
|
256
|
+
if (!isPathAllowed(resolved, config.allowedPaths)) {
|
|
257
|
+
return `Error: MCP tool argument "${key}" references path outside allowed directories: ${value}`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
244
262
|
return await executeMcpToolGeneric(name, input);
|
|
245
263
|
}
|
|
246
264
|
// Route spawn_subagent
|
|
@@ -264,6 +282,69 @@ export async function executeTool(name, input, config, context) {
|
|
|
264
282
|
}
|
|
265
283
|
// Map Claude Code names to internal names for built-in tools
|
|
266
284
|
const normalized = fromClaudeCodeName(name).toLowerCase().replace(/-/g, '_');
|
|
285
|
+
// --- Sandbox routing ---
|
|
286
|
+
const sandboxCfg = context?.sandboxConfig;
|
|
287
|
+
if (sandboxCfg?.enabled) {
|
|
288
|
+
const SANDBOXED_TOOLS = new Set(['bash', 'read_file', 'write_file', 'list_directory', 'glob']);
|
|
289
|
+
// macOS-only commands that must run on the host (not available in Linux containers)
|
|
290
|
+
const MACOS_HOST_COMMANDS = new Set([
|
|
291
|
+
'osascript', 'open', 'say', 'pbcopy', 'pbpaste', 'defaults',
|
|
292
|
+
'icalBuddy', 'shortcuts', 'caffeinate', 'networksetup', 'launchctl',
|
|
293
|
+
'security', 'xattr', 'ditto', 'hdiutil', 'diskutil', 'sw_vers',
|
|
294
|
+
]);
|
|
295
|
+
const needsHost = normalized === 'bash' && input.command &&
|
|
296
|
+
MACOS_HOST_COMMANDS.has(input.command.trim().split(/[\s;|&]/)[0]);
|
|
297
|
+
if (SANDBOXED_TOOLS.has(normalized) && !needsHost) {
|
|
298
|
+
const sessionId = context?.sessionId || context?.chatId?.toString() || 'default';
|
|
299
|
+
const merged = { ...SANDBOX_DEFAULTS, ...sandboxCfg };
|
|
300
|
+
const containerName = await ensureContainer(sessionId, merged, config.allowedPaths);
|
|
301
|
+
const mounts = validateMountPaths(config.allowedPaths);
|
|
302
|
+
const tp = (p) => translatePath(p, mounts);
|
|
303
|
+
// Translate host paths in bash commands so they resolve inside the container
|
|
304
|
+
const translateBashPaths = (cmd) => {
|
|
305
|
+
let translated = cmd;
|
|
306
|
+
// Sort mounts by host path length descending to match most specific first
|
|
307
|
+
const sorted = [...mounts].sort((a, b) => b.host.length - a.host.length);
|
|
308
|
+
for (const mount of sorted) {
|
|
309
|
+
translated = translated.replaceAll(mount.host, mount.container);
|
|
310
|
+
}
|
|
311
|
+
// Also translate ~ and $HOME references to /workspace/config
|
|
312
|
+
const home = homedir();
|
|
313
|
+
const homeMounts = sorted.filter(m => m.host.startsWith(home));
|
|
314
|
+
for (const mount of homeMounts) {
|
|
315
|
+
const tildeForm = '~' + mount.host.slice(home.length);
|
|
316
|
+
translated = translated.replaceAll(tildeForm, mount.container);
|
|
317
|
+
const envForm = '$HOME' + mount.host.slice(home.length);
|
|
318
|
+
translated = translated.replaceAll(envForm, mount.container);
|
|
319
|
+
}
|
|
320
|
+
return translated;
|
|
321
|
+
};
|
|
322
|
+
// Reverse-translate container paths back to host paths in file content.
|
|
323
|
+
// Prevents the agent from writing /workspace/... paths into config files.
|
|
324
|
+
const reverseTranslatePaths = (content) => {
|
|
325
|
+
let reversed = content;
|
|
326
|
+
const sorted = [...mounts].sort((a, b) => b.container.length - a.container.length);
|
|
327
|
+
for (const mount of sorted) {
|
|
328
|
+
reversed = reversed.replaceAll(mount.container, mount.host);
|
|
329
|
+
}
|
|
330
|
+
return reversed;
|
|
331
|
+
};
|
|
332
|
+
switch (normalized) {
|
|
333
|
+
case 'bash':
|
|
334
|
+
return await sandboxBash(containerName, translateBashPaths(input.command), input.cwd ? tp(input.cwd) : undefined, config.bashTimeout);
|
|
335
|
+
case 'read_file':
|
|
336
|
+
return await sandboxReadFile(containerName, tp(input.file_path || input.path));
|
|
337
|
+
case 'write_file':
|
|
338
|
+
return await sandboxWriteFile(containerName, tp(input.file_path || input.path), reverseTranslatePaths(input.content));
|
|
339
|
+
case 'list_directory':
|
|
340
|
+
return await sandboxListDir(containerName, tp(input.path));
|
|
341
|
+
case 'glob':
|
|
342
|
+
return await sandboxGlob(containerName, tp(input.base || input.path || '/workspace'), input.pattern || '*');
|
|
343
|
+
default:
|
|
344
|
+
break; // fall through
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
267
348
|
switch (normalized) {
|
|
268
349
|
case '$web_search':
|
|
269
350
|
case 'web_search':
|
|
@@ -300,7 +381,8 @@ async function executeSpawnSubagent(input, context) {
|
|
|
300
381
|
const type = input.type;
|
|
301
382
|
const model = input.model;
|
|
302
383
|
const label = input.label;
|
|
303
|
-
|
|
384
|
+
// NOTE: allowedPaths deliberately NOT accepted from model input (security — prevents path escalation).
|
|
385
|
+
// Subagents inherit their preset's allowedPaths from config.
|
|
304
386
|
if (!task || !type) {
|
|
305
387
|
return 'Error: task and type are required';
|
|
306
388
|
}
|
|
@@ -308,7 +390,7 @@ async function executeSpawnSubagent(input, context) {
|
|
|
308
390
|
return `Error: Invalid type "${type}". Must be coding or research.`;
|
|
309
391
|
}
|
|
310
392
|
try {
|
|
311
|
-
const subagentTask = dispatchSubagent(type, task, context.chatId, context.fullConfig, model, context.history, { label
|
|
393
|
+
const subagentTask = dispatchSubagent(type, task, context.chatId, context.fullConfig, model, context.history, { label });
|
|
312
394
|
const labelStr = label ? ` "${label}"` : '';
|
|
313
395
|
return JSON.stringify({
|
|
314
396
|
status: 'accepted',
|
package/dist/types.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export interface Config {
|
|
|
79
79
|
/** Named project paths. Keys are short names (e.g. "skimpyclaw"), values are absolute paths.
|
|
80
80
|
* Project paths are automatically added to tool allowedPaths and available to code_with_agent by name. */
|
|
81
81
|
projects?: Record<string, string>;
|
|
82
|
+
sandbox?: SandboxConfig;
|
|
82
83
|
}
|
|
83
84
|
export interface AgentConfig {
|
|
84
85
|
identity: {
|
|
@@ -134,6 +135,15 @@ export interface CronPayload {
|
|
|
134
135
|
tools?: ToolConfig;
|
|
135
136
|
sendAsVoice?: boolean;
|
|
136
137
|
}
|
|
138
|
+
export interface SandboxConfig {
|
|
139
|
+
enabled: boolean;
|
|
140
|
+
runtime?: 'container' | 'docker';
|
|
141
|
+
image?: string;
|
|
142
|
+
cpus?: number;
|
|
143
|
+
memory?: string;
|
|
144
|
+
network?: string;
|
|
145
|
+
idleTimeoutMs?: number;
|
|
146
|
+
}
|
|
137
147
|
export interface ToolConfig {
|
|
138
148
|
enabled: boolean;
|
|
139
149
|
allowedPaths: string[];
|