skimpyclaw 0.1.9 → 0.3.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 (61) 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__/cron.test.js +51 -1
  4. package/dist/__tests__/doctor.runner.test.js +5 -1
  5. package/dist/__tests__/heartbeat.test.js +5 -5
  6. package/dist/__tests__/sandbox-bridge.test.d.ts +1 -0
  7. package/dist/__tests__/sandbox-bridge.test.js +116 -0
  8. package/dist/__tests__/sandbox-manager.test.d.ts +1 -0
  9. package/dist/__tests__/sandbox-manager.test.js +119 -0
  10. package/dist/__tests__/sandbox-mount-security.test.d.ts +1 -0
  11. package/dist/__tests__/sandbox-mount-security.test.js +131 -0
  12. package/dist/__tests__/sandbox-runtime.test.d.ts +1 -0
  13. package/dist/__tests__/sandbox-runtime.test.js +176 -0
  14. package/dist/__tests__/setup.test.js +28 -2
  15. package/dist/__tests__/skills.test.js +2 -11
  16. package/dist/__tests__/tools.test.js +6 -1
  17. package/dist/agent.js +3 -0
  18. package/dist/api.js +5 -1
  19. package/dist/channels/telegram/utils.js +2 -2
  20. package/dist/cli.js +212 -0
  21. package/dist/code-agents/executor.js +17 -4
  22. package/dist/code-agents/types.d.ts +5 -0
  23. package/dist/cron.d.ts +6 -0
  24. package/dist/cron.js +59 -3
  25. package/dist/discord.js +2 -2
  26. package/dist/doctor/checks.d.ts +1 -0
  27. package/dist/doctor/checks.js +47 -0
  28. package/dist/doctor/runner.js +2 -1
  29. package/dist/exec-approval.d.ts +4 -0
  30. package/dist/exec-approval.js +4 -4
  31. package/dist/gateway.js +33 -2
  32. package/dist/heartbeat.js +3 -0
  33. package/dist/providers/anthropic.js +1 -1
  34. package/dist/providers/codex.js +1 -1
  35. package/dist/providers/openai.js +2 -2
  36. package/dist/sandbox/bridge.d.ts +5 -0
  37. package/dist/sandbox/bridge.js +63 -0
  38. package/dist/sandbox/index.d.ts +5 -0
  39. package/dist/sandbox/index.js +4 -0
  40. package/dist/sandbox/manager.d.ts +7 -0
  41. package/dist/sandbox/manager.js +89 -0
  42. package/dist/sandbox/mount-security.d.ts +12 -0
  43. package/dist/sandbox/mount-security.js +118 -0
  44. package/dist/sandbox/runtime.d.ts +38 -0
  45. package/dist/sandbox/runtime.js +187 -0
  46. package/dist/service.js +25 -0
  47. package/dist/setup.d.ts +11 -0
  48. package/dist/setup.js +335 -13
  49. package/dist/skills.d.ts +1 -2
  50. package/dist/skills.js +1 -13
  51. package/dist/tools/bash-path-validation.d.ts +22 -0
  52. package/dist/tools/bash-path-validation.js +130 -0
  53. package/dist/tools/bash-tool.js +23 -1
  54. package/dist/tools/definitions.d.ts +0 -7
  55. package/dist/tools/definitions.js +0 -5
  56. package/dist/tools/execute-context.d.ts +6 -0
  57. package/dist/tools/path-utils.js +16 -2
  58. package/dist/tools.js +84 -2
  59. package/dist/types.d.ts +10 -0
  60. package/dist/voice.js +9 -2
  61. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -43,6 +43,10 @@ Commands:
43
43
  tools list List available tools (built-in + MCP)
44
44
  tools install <name> Add MCP server (--command <cmd> [--args ...] or --url <url>)
45
45
  tools remove <name> Remove MCP server
46
+ sandbox status Show active sandbox containers
47
+ sandbox prune Force-prune all sandbox containers
48
+ sandbox init Auto-setup sandbox runtime/image/config (supports --profile)
49
+ sandbox doctor Sandbox-specific diagnostics and hints
46
50
  help Show this help
47
51
  `);
48
52
  }
@@ -724,6 +728,211 @@ async function commandTools(args) {
724
728
  console.error('Usage: skimpyclaw tools <list|install|remove>');
725
729
  return 1;
726
730
  }
731
+ const SANDBOX_CLI_BY_PROFILE = {
732
+ minimal: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm'],
733
+ dev: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make'],
734
+ full: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make', 'pip3', 'sqlite3'],
735
+ };
736
+ function defaultSandboxNetwork(runtime) {
737
+ return runtime === 'container' ? 'default' : 'bridge';
738
+ }
739
+ function detectSandboxRuntime(preferred) {
740
+ if (preferred === 'container' || preferred === 'docker') {
741
+ return spawnSync(preferred, ['--version'], { encoding: 'utf-8' }).status === 0 ? preferred : null;
742
+ }
743
+ if (spawnSync('container', ['--version'], { encoding: 'utf-8' }).status === 0) {
744
+ return 'container';
745
+ }
746
+ if (spawnSync('docker', ['--version'], { encoding: 'utf-8' }).status === 0) {
747
+ return 'docker';
748
+ }
749
+ return null;
750
+ }
751
+ function isSandboxRuntimeRunning(runtime) {
752
+ if (runtime === 'container') {
753
+ return spawnSync('container', ['system', 'status'], { encoding: 'utf-8' }).status === 0;
754
+ }
755
+ return spawnSync('docker', ['info'], { encoding: 'utf-8' }).status === 0;
756
+ }
757
+ function sandboxNetworkExists(runtime, network) {
758
+ if (runtime === 'container') {
759
+ const result = spawnSync('container', ['network', 'ls'], { encoding: 'utf-8' });
760
+ if (result.status !== 0)
761
+ return false;
762
+ return result.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === network);
763
+ }
764
+ const result = spawnSync('docker', ['network', 'inspect', network], { encoding: 'utf-8' });
765
+ return result.status === 0;
766
+ }
767
+ function sandboxImageExists(runtime, image) {
768
+ return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
769
+ }
770
+ function resolveSandboxDir() {
771
+ const cwdSandbox = join(process.cwd(), 'sandbox');
772
+ if (existsSync(join(cwdSandbox, 'Dockerfile'))) {
773
+ return cwdSandbox;
774
+ }
775
+ return null;
776
+ }
777
+ function parseSandboxOption(args, flag) {
778
+ const idx = args.indexOf(flag);
779
+ if (idx === -1 || idx + 1 >= args.length)
780
+ return undefined;
781
+ return args[idx + 1];
782
+ }
783
+ function runSandboxImageCheck(runtime, image, network, cmd) {
784
+ const result = spawnSync(runtime, ['run', '--rm', '--network', network, image, 'sh', '-lc', cmd], { encoding: 'utf-8' });
785
+ if (result.status === 0) {
786
+ return { ok: true, detail: (result.stdout || '').trim() || 'ok' };
787
+ }
788
+ const detail = `${(result.stderr || '').trim()} ${(result.stdout || '').trim()}`.trim() || `exit ${result.status ?? 1}`;
789
+ return { ok: false, detail };
790
+ }
791
+ function printSandboxCheck(ok, name, detail, hint) {
792
+ const prefix = ok ? '✓' : '✗';
793
+ console.log(`${prefix} ${name}: ${detail}`);
794
+ if (!ok && hint) {
795
+ console.log(` → ${hint}`);
796
+ }
797
+ }
798
+ async function commandSandbox(args) {
799
+ const sub = args[0];
800
+ if (sub === 'status') {
801
+ const rt = detectSandboxRuntime();
802
+ if (!rt) {
803
+ console.log('No container runtime found (install Docker or Apple Containers).');
804
+ return 1;
805
+ }
806
+ const result = spawnSync(rt, ['ps', '--format', '{{.Names}}'], { encoding: 'utf-8' });
807
+ const lines = (result.stdout || '').trim().split('\n').filter(Boolean);
808
+ const containers = lines.filter((line) => line.includes('skimpyclaw-sbx'));
809
+ if (containers.length === 0) {
810
+ console.log('No active sandbox containers.');
811
+ }
812
+ else {
813
+ console.log(`Active sandbox containers (${containers.length}):`);
814
+ containers.forEach((c) => console.log(` ${c}`));
815
+ }
816
+ return 0;
817
+ }
818
+ if (sub === 'prune') {
819
+ const { cleanupOrphans } = await import('./sandbox/index.js');
820
+ const count = await cleanupOrphans();
821
+ console.log(`Pruned ${count} sandbox container(s).`);
822
+ return 0;
823
+ }
824
+ if (sub === 'init') {
825
+ const runtimeFlag = parseSandboxOption(args, '--runtime');
826
+ const profileFlag = parseSandboxOption(args, '--profile') || 'minimal';
827
+ const imageFlag = parseSandboxOption(args, '--image');
828
+ const networkFlag = parseSandboxOption(args, '--network');
829
+ const validProfiles = ['minimal', 'dev', 'full'];
830
+ if (!validProfiles.includes(profileFlag)) {
831
+ console.error(`Invalid profile "${profileFlag}". Use one of: minimal, dev, full`);
832
+ return 1;
833
+ }
834
+ const profile = profileFlag;
835
+ const runtime = detectSandboxRuntime(runtimeFlag);
836
+ if (!runtime) {
837
+ console.error('No supported runtime found. Install Apple Containers or Docker.');
838
+ return 1;
839
+ }
840
+ const network = networkFlag || defaultSandboxNetwork(runtime);
841
+ const image = imageFlag || 'skimpyclaw-sandbox:latest';
842
+ if (!isSandboxRuntimeRunning(runtime)) {
843
+ const hint = runtime === 'container' ? 'Run: container system start' : 'Start Docker Desktop (or run `docker info`).';
844
+ console.error(`Runtime "${runtime}" is not running.`);
845
+ console.error(hint);
846
+ return 1;
847
+ }
848
+ if (!sandboxNetworkExists(runtime, network)) {
849
+ const hint = runtime === 'container'
850
+ ? 'Create/list networks with `container network ls`.'
851
+ : 'Create/list networks with `docker network ls`.';
852
+ console.error(`Sandbox network "${network}" not found for runtime "${runtime}".`);
853
+ console.error(hint);
854
+ return 1;
855
+ }
856
+ const sandboxDir = resolveSandboxDir();
857
+ if (!sandboxDir) {
858
+ console.error('Could not find sandbox/Dockerfile from current directory.');
859
+ console.error('Run from repo root (contains ./sandbox) or build image manually.');
860
+ return 1;
861
+ }
862
+ console.log(`Building sandbox image "${image}" (runtime=${runtime}, profile=${profile})...`);
863
+ const build = spawnSync(runtime, ['build', '--build-arg', `SKIMPY_PROFILE=${profile}`, '-t', image, sandboxDir], { stdio: 'inherit' });
864
+ if (build.status !== 0) {
865
+ console.error('Sandbox image build failed.');
866
+ return 1;
867
+ }
868
+ const raw = loadRawConfig();
869
+ const sandbox = raw.sandbox ?? {};
870
+ sandbox.enabled = true;
871
+ sandbox.runtime = runtime;
872
+ sandbox.network = network;
873
+ sandbox.image = image;
874
+ raw.sandbox = sandbox;
875
+ saveConfig(raw);
876
+ console.log('Updated config: sandbox.enabled=true');
877
+ console.log(`Updated config: sandbox.runtime="${runtime}"`);
878
+ console.log(`Updated config: sandbox.network="${network}"`);
879
+ console.log(`Updated config: sandbox.image="${image}"`);
880
+ const required = SANDBOX_CLI_BY_PROFILE[profile];
881
+ const checkCmd = `for c in ${required.join(' ')}; do command -v "$c" >/dev/null || { echo "missing:$c"; exit 1; }; done; echo cli-ok`;
882
+ const cliCheck = runSandboxImageCheck(runtime, image, network, checkCmd);
883
+ const netCheck = runSandboxImageCheck(runtime, image, network, 'curl -fsS --max-time 8 https://example.com >/dev/null && echo net-ok');
884
+ const hostCheck = runSandboxImageCheck(runtime, image, network, 'hostname');
885
+ printSandboxCheck(hostCheck.ok, 'sandbox_hostname', hostCheck.detail);
886
+ printSandboxCheck(cliCheck.ok, 'sandbox_tools', cliCheck.detail, 'Rebuild image or choose a lighter profile.');
887
+ printSandboxCheck(netCheck.ok, 'sandbox_network_egress', netCheck.detail, 'Try a different sandbox.network or check runtime DNS/network settings.');
888
+ if (!hostCheck.ok || !cliCheck.ok || !netCheck.ok) {
889
+ return 1;
890
+ }
891
+ console.log('\nSandbox init complete. Restart Skimpy to apply runtime config.');
892
+ return 0;
893
+ }
894
+ if (sub === 'doctor') {
895
+ const config = loadConfig();
896
+ const runtime = detectSandboxRuntime(config.sandbox?.runtime);
897
+ const image = config.sandbox?.image || 'skimpyclaw-sandbox:latest';
898
+ const network = config.sandbox?.network || (runtime ? defaultSandboxNetwork(runtime) : 'unknown');
899
+ const profileFlag = parseSandboxOption(args, '--profile') || 'minimal';
900
+ const profile = (['minimal', 'dev', 'full'].includes(profileFlag) ? profileFlag : 'minimal');
901
+ let failed = false;
902
+ printSandboxCheck(config.sandbox?.enabled === true, 'sandbox_enabled', config.sandbox?.enabled ? 'enabled' : 'disabled', 'Run: skimpyclaw sandbox init');
903
+ if (!config.sandbox?.enabled)
904
+ failed = true;
905
+ printSandboxCheck(!!runtime, 'runtime_detected', runtime || 'none', 'Install Docker or Apple Containers.');
906
+ if (!runtime)
907
+ return 1;
908
+ printSandboxCheck(isSandboxRuntimeRunning(runtime), 'runtime_running', runtime, runtime === 'container' ? 'Run: container system start' : 'Start Docker Desktop.');
909
+ if (!isSandboxRuntimeRunning(runtime))
910
+ failed = true;
911
+ const networkOk = sandboxNetworkExists(runtime, network);
912
+ printSandboxCheck(networkOk, 'network_exists', network, `Use "${runtime === 'container' ? 'container' : 'docker'} network ls" and update sandbox.network.`);
913
+ if (!networkOk)
914
+ failed = true;
915
+ const imageOk = sandboxImageExists(runtime, image);
916
+ printSandboxCheck(imageOk, 'image_exists', image, `Build image: ${runtime} build -t ${image} sandbox/`);
917
+ if (!imageOk)
918
+ failed = true;
919
+ if (imageOk && networkOk) {
920
+ const required = SANDBOX_CLI_BY_PROFILE[profile];
921
+ const checkCmd = `for c in ${required.join(' ')}; do command -v "$c" >/dev/null || { echo "missing:$c"; exit 1; }; done; echo cli-ok`;
922
+ const cliCheck = runSandboxImageCheck(runtime, image, network, checkCmd);
923
+ printSandboxCheck(cliCheck.ok, 'image_toolchain', cliCheck.detail, 'Rebuild with: skimpyclaw sandbox init --profile dev');
924
+ if (!cliCheck.ok)
925
+ failed = true;
926
+ const netCheck = runSandboxImageCheck(runtime, image, network, 'curl -fsS --max-time 8 https://api.duckduckgo.com/?q=skimpyclaw&format=json >/dev/null && echo net-ok');
927
+ printSandboxCheck(netCheck.ok, 'network_egress', netCheck.detail, 'Some sources may timeout; verify DNS/network in runtime.');
928
+ if (!netCheck.ok)
929
+ failed = true;
930
+ }
931
+ return failed ? 1 : 0;
932
+ }
933
+ console.log('Usage: skimpyclaw sandbox <status|prune|init|doctor>');
934
+ return 1;
935
+ }
727
936
  export async function runCli(argv = process.argv.slice(2)) {
728
937
  const [command, ...args] = argv;
729
938
  if (!command || command === 'help' || command === '--help' || command === '-h') {
@@ -787,6 +996,9 @@ export async function runCli(argv = process.argv.slice(2)) {
787
996
  if (command === 'tools') {
788
997
  return await commandTools(args);
789
998
  }
999
+ if (command === 'sandbox') {
1000
+ return await commandSandbox(args);
1001
+ }
790
1002
  console.error(`Unknown command: ${command}`);
791
1003
  printHelp();
792
1004
  return 1;
@@ -10,6 +10,7 @@ import { buildCodeAgentArgs, notifyCodeAgentResult } from './utils.js';
10
10
  import { parseStreamJsonForLive, parseClaudeOutput, parseCodexOutput } from './parser.js';
11
11
  import { startTrace, addEvent, endTrace } from '../audit.js';
12
12
  import { buildUsageRecord, recordUsage } from '../usage.js';
13
+ import { ensureContainer, SANDBOX_DEFAULTS, getRuntime } from '../sandbox/index.js';
13
14
  const CANCELLED_MESSAGE = 'Cancelled by user';
14
15
  /** Run build/test validation. Shared by solo agents and team orchestrator. */
15
16
  export function runValidation(workdir) {
@@ -87,6 +88,13 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
87
88
  logStream.write(`Task: ${task.slice(0, 500)}\n`);
88
89
  logStream.write(`Workdir: ${workdir}\n\n`);
89
90
  try {
91
+ // Resolve sandbox container name if enabled (used for spawn wrapping)
92
+ let sandboxContainer;
93
+ if (options?.sandboxConfig?.enabled) {
94
+ const merged = { ...SANDBOX_DEFAULTS, ...options.sandboxConfig };
95
+ sandboxContainer = await ensureContainer(`code-${id}`, merged, options.allowedPaths || [workdir]);
96
+ console.log(`[code-agent] Running in sandbox container: ${sandboxContainer}`);
97
+ }
90
98
  ensureNotCancelled();
91
99
  const exitCode = await new Promise((resolvePromise, reject) => {
92
100
  const spawnEnv = { ...process.env };
@@ -94,8 +102,11 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
94
102
  // Apply extra env vars (e.g. team mode feature flag)
95
103
  if (options?.env)
96
104
  Object.assign(spawnEnv, options.env);
97
- const proc = spawn(cmd, args, {
98
- cwd: workdir,
105
+ // When sandbox is enabled, wrap the spawn: container exec <name> <cmd> <args>
106
+ const spawnCmd = sandboxContainer ? getRuntime() : cmd;
107
+ const spawnArgs = sandboxContainer ? ['exec', sandboxContainer, cmd, ...args] : args;
108
+ const proc = spawn(spawnCmd, spawnArgs, {
109
+ cwd: sandboxContainer ? undefined : workdir, // container has its own cwd
99
110
  stdio: ['ignore', 'pipe', 'pipe'],
100
111
  env: spawnEnv,
101
112
  });
@@ -277,8 +288,10 @@ export async function runCodeAgentBackground(id, agent, task, workdir, validate,
277
288
  const retryExitCode = await new Promise((resolveRetry, rejectRetry) => {
278
289
  const spawnEnv = { ...process.env };
279
290
  delete spawnEnv.CLAUDECODE;
280
- const retryProc = spawn(retryCmd, retryArgs, {
281
- cwd: workdir,
291
+ const retrySpawnCmd = sandboxContainer ? getRuntime() : retryCmd;
292
+ const retrySpawnArgs = sandboxContainer ? ['exec', sandboxContainer, retryCmd, ...retryArgs] : retryArgs;
293
+ const retryProc = spawn(retrySpawnCmd, retrySpawnArgs, {
294
+ cwd: sandboxContainer ? undefined : workdir,
282
295
  stdio: ['ignore', 'pipe', 'pipe'],
283
296
  env: spawnEnv,
284
297
  });
@@ -1,3 +1,4 @@
1
+ import type { SandboxConfig } from '../types.js';
1
2
  export interface CodeAgentTask {
2
3
  id: string;
3
4
  agent: string;
@@ -44,6 +45,10 @@ export interface CodeAgentBackgroundOptions {
44
45
  maxTimeoutMinutes?: number;
45
46
  /** Skip sending notification on completion (parent handles it) */
46
47
  skipNotification?: boolean;
48
+ /** Sandbox configuration — when enabled, run CLI inside container */
49
+ sandboxConfig?: SandboxConfig;
50
+ /** Paths to mount into the sandbox container */
51
+ allowedPaths?: string[];
47
52
  }
48
53
  export interface BuildCodeAgentArgsInput {
49
54
  task: string;
package/dist/cron.d.ts CHANGED
@@ -23,6 +23,12 @@ export declare function parseDualOutput(response: string): {
23
23
  voice: string | null;
24
24
  text: string;
25
25
  };
26
+ /**
27
+ * Post-run guard for the pr-review cron job.
28
+ * Validates that the agent actually used code_with_agent when PRs were found.
29
+ * Non-throwing — logs a warning and returns an alert message (or null if OK).
30
+ */
31
+ export declare function validatePrReviewOutput(output: string): string | null;
26
32
  export declare function getCronJobs(): {
27
33
  id: string;
28
34
  name: string;
package/dist/cron.js CHANGED
@@ -9,6 +9,7 @@ import { startTrace, addEvent, endTrace } from './audit.js';
9
9
  import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
10
10
  import { parseAndSaveDigest } from './digests.js';
11
11
  import { synthesizeSpeech } from './voice.js';
12
+ import { ensureContainer, SANDBOX_DEFAULTS, sandboxBash } from './sandbox/index.js';
12
13
  const scheduledJobs = new Map();
13
14
  let configWatcher = null;
14
15
  function getCronLogDir() {
@@ -138,7 +139,7 @@ async function executeJobPayload(jobDef, config) {
138
139
  channel: getActiveChannelId() || 'telegram',
139
140
  trigger: 'cron',
140
141
  sessionId: jobDef.id,
141
- metadata: { jobName: jobDef.name },
142
+ metadata: { jobName: jobDef.name, isCronJob: true },
142
143
  });
143
144
  appendCronLogLine(jobDef.id, `Agent turn completed (${response.length} chars)`);
144
145
  // Parse dual output (voice + text) if delimiters present
@@ -148,6 +149,19 @@ async function executeJobPayload(jobDef, config) {
148
149
  }
149
150
  // Use text portion for log output and notifications
150
151
  logEntry.output = textPortion.slice(0, 5000);
152
+ // Post-run guard for pr-review job
153
+ if (jobDef.id === 'pr-review') {
154
+ const guardAlert = validatePrReviewOutput(textPortion);
155
+ if (guardAlert) {
156
+ appendCronLogLine(jobDef.id, guardAlert);
157
+ try {
158
+ await sendActiveChannelProactiveMessage(config, guardAlert);
159
+ }
160
+ catch {
161
+ // Non-critical
162
+ }
163
+ }
164
+ }
151
165
  // Parse and save digest from the text portion
152
166
  try {
153
167
  parseAndSaveDigest(jobDef.id, jobDef.name, textPortion);
@@ -189,7 +203,7 @@ async function executeJobPayload(jobDef, config) {
189
203
  });
190
204
  appendCronLogLine(jobDef.id, `Script started: ${(jobDef.payload.script || '').slice(0, 100)}`);
191
205
  try {
192
- const output = await executeScript(jobDef);
206
+ const output = await executeScript(jobDef, config);
193
207
  logEntry.output = output.slice(0, 50000);
194
208
  appendCronLogLine(jobDef.id, `Script completed (${output.length} chars)`);
195
209
  addEvent(scriptTraceId, {
@@ -248,7 +262,7 @@ async function executeJobPayload(jobDef, config) {
248
262
  }
249
263
  }
250
264
  }
251
- async function executeScript(jobDef) {
265
+ async function executeScript(jobDef, config) {
252
266
  const script = expandVariables(jobDef.payload.script || '');
253
267
  if (!script) {
254
268
  throw new Error(`Script payload is empty for job: ${jobDef.id}`);
@@ -258,6 +272,19 @@ async function executeScript(jobDef) {
258
272
  throw new Error(`Working directory does not exist: ${cwd}`);
259
273
  }
260
274
  const timeoutMs = jobDef.payload.timeoutMs || 600000; // 10 min default
275
+ // Sandbox routing for script payloads
276
+ const sandboxCfg = config.sandbox;
277
+ if (sandboxCfg?.enabled) {
278
+ const merged = { ...SANDBOX_DEFAULTS, ...sandboxCfg };
279
+ const containerName = await ensureContainer(`cron-${jobDef.id}`, merged, jobDef.payload.tools?.allowedPaths || []);
280
+ console.log(`[cron:script] Running in sandbox container: ${containerName}`);
281
+ console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
282
+ if (cwd)
283
+ console.log(`[cron:script] cwd: ${cwd}`);
284
+ const output = await sandboxBash(containerName, script, cwd, timeoutMs);
285
+ console.log(`[cron:script] Sandbox completed (${output.length} chars)`);
286
+ return output;
287
+ }
261
288
  return new Promise((resolve, reject) => {
262
289
  const startTime = Date.now();
263
290
  console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
@@ -342,6 +369,35 @@ export function parseDualOutput(response) {
342
369
  text: text || response,
343
370
  };
344
371
  }
372
+ /**
373
+ * Post-run guard for the pr-review cron job.
374
+ * Validates that the agent actually used code_with_agent when PRs were found.
375
+ * Non-throwing — logs a warning and returns an alert message (or null if OK).
376
+ */
377
+ export function validatePrReviewOutput(output) {
378
+ // Check for the machine-readable result line
379
+ const resultMatch = output.match(/\[PR_REVIEW_RESULT:\s*(.+?)\]/);
380
+ if (!resultMatch) {
381
+ return '⚠️ PR Pre-Review: Missing [PR_REVIEW_RESULT] line in output. The agent may not have followed the prompt correctly.';
382
+ }
383
+ const resultLine = resultMatch[1].trim();
384
+ // NO_CANDIDATES is fine — nothing to review
385
+ if (resultLine === 'NO_CANDIDATES') {
386
+ return null;
387
+ }
388
+ // Parse CANDIDATES=N CODE_AGENT_CALLS=M BLOCKED=B
389
+ const candidatesMatch = resultLine.match(/CANDIDATES=(\d+)/);
390
+ const callsMatch = resultLine.match(/CODE_AGENT_CALLS=(\d+)/);
391
+ const blockedMatch = resultLine.match(/BLOCKED=(\d+)/);
392
+ const candidates = candidatesMatch ? parseInt(candidatesMatch[1], 10) : 0;
393
+ const calls = callsMatch ? parseInt(callsMatch[1], 10) : 0;
394
+ const blocked = blockedMatch ? parseInt(blockedMatch[1], 10) : 0;
395
+ // If there were candidates but zero code_with_agent calls (and not all blocked), alert
396
+ if (candidates > 0 && calls === 0 && blocked < candidates) {
397
+ return `⚠️ PR Pre-Review: Found ${candidates} PR candidate(s) but code_with_agent was never called (blocked: ${blocked}). The agent likely wrote inline commentary instead of delegating.`;
398
+ }
399
+ return null;
400
+ }
345
401
  function expandVariables(message) {
346
402
  const now = new Date();
347
403
  const date = now.toLocaleDateString('en-US', {
package/dist/discord.js CHANGED
@@ -47,8 +47,8 @@ const chatHistory = new Map();
47
47
  const loadedFromDisk = new Set();
48
48
  const DEFAULT_DISCORD_TOOLS = {
49
49
  enabled: true,
50
- allowedPaths: [join(homedir(), '.skimpyclaw'), process.cwd()],
51
- maxIterations: 100,
50
+ allowedPaths: [join(homedir(), '.skimpyclaw')],
51
+ maxIterations: 30,
52
52
  bashTimeout: 15000,
53
53
  };
54
54
  let client = null;
@@ -15,4 +15,5 @@ export declare function checkVoiceDependencies(config: Config): Promise<DoctorCh
15
15
  export declare function checkMcpConfig(config: Config): Promise<DoctorCheckResult>;
16
16
  export declare function checkGatewayHostBindable(host: string): Promise<DoctorCheckResult>;
17
17
  export declare function checkSkimpyclawDirWritable(): Promise<DoctorCheckResult>;
18
+ export declare function checkSandboxAvailable(config: Config): Promise<DoctorCheckResult>;
18
19
  export declare function checkPortAvailability(port: number): Promise<DoctorCheckResult>;
@@ -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';
@@ -122,7 +122,7 @@ export async function chatWithToolsAnthropic(params) {
122
122
  const modelId = stripProvider(options.model);
123
123
  const maxIterations = toolConfig.maxIterations || 20;
124
124
  // Resolve tools once at start of agent loop
125
- const includeSpawn = !!(toolContext?.chatId && toolContext?.fullConfig);
125
+ const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
126
126
  const toolDefs = await getToolDefinitions(toolConfig, { includeSpawnSubagent: includeSpawn, projects: toolContext?.fullConfig?.projects });
127
127
  // Enable prompt caching for system + tools
128
128
  const cacheEnabled = config.models?.promptCaching !== false;
@@ -286,7 +286,7 @@ export async function chatWithToolsCodex(params) {
286
286
  }
287
287
  // Get tool definitions
288
288
  const { getToolDefinitions } = await import('../tools.js');
289
- const includeSpawn = !!(toolContext?.chatId && toolContext?.fullConfig);
289
+ const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
290
290
  const toolDefs = await getToolDefinitions(toolConfig, {
291
291
  includeSpawnSubagent: includeSpawn,
292
292
  includeMcp: false,