skyloom 1.14.4 → 1.14.6

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/cli/loom.d.ts +2 -0
  2. package/dist/cli/loom.d.ts.map +1 -1
  3. package/dist/cli/loom.js +76 -7
  4. package/dist/cli/loom.js.map +1 -1
  5. package/dist/cli/main.js +12 -3
  6. package/dist/cli/main.js.map +1 -1
  7. package/dist/core/agent/guard.d.ts.map +1 -1
  8. package/dist/core/agent/guard.js +1 -2
  9. package/dist/core/agent/guard.js.map +1 -1
  10. package/dist/core/agent.d.ts.map +1 -1
  11. package/dist/core/agent.js +12 -9
  12. package/dist/core/agent.js.map +1 -1
  13. package/dist/core/agent_helpers.d.ts +3 -3
  14. package/dist/core/agent_helpers.d.ts.map +1 -1
  15. package/dist/core/agent_helpers.js +7 -3
  16. package/dist/core/agent_helpers.js.map +1 -1
  17. package/dist/core/config.d.ts.map +1 -1
  18. package/dist/core/config.js +8 -2
  19. package/dist/core/config.js.map +1 -1
  20. package/dist/core/memory.d.ts.map +1 -1
  21. package/dist/core/memory.js +12 -2
  22. package/dist/core/memory.js.map +1 -1
  23. package/dist/core/model_config.d.ts.map +1 -1
  24. package/dist/core/model_config.js +7 -2
  25. package/dist/core/model_config.js.map +1 -1
  26. package/dist/plugins/loader.d.ts +7 -0
  27. package/dist/plugins/loader.d.ts.map +1 -1
  28. package/dist/plugins/loader.js +27 -0
  29. package/dist/plugins/loader.js.map +1 -1
  30. package/dist/tools/builtin.d.ts +6 -0
  31. package/dist/tools/builtin.d.ts.map +1 -1
  32. package/dist/tools/builtin.js +160 -17
  33. package/dist/tools/builtin.js.map +1 -1
  34. package/dist/tools/computer.d.ts.map +1 -1
  35. package/dist/tools/computer.js +18 -7
  36. package/dist/tools/computer.js.map +1 -1
  37. package/dist/web/server.d.ts.map +1 -1
  38. package/dist/web/server.js +35 -2
  39. package/dist/web/server.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/loom.ts +66 -7
  42. package/src/cli/main.ts +6 -3
  43. package/src/core/agent/guard.ts +1 -2
  44. package/src/core/agent.ts +12 -8
  45. package/src/core/agent_helpers.ts +7 -3
  46. package/src/core/config.ts +5 -2
  47. package/src/core/memory.ts +9 -2
  48. package/src/core/model_config.ts +4 -2
  49. package/src/plugins/loader.ts +91 -66
  50. package/src/tools/builtin.ts +119 -16
  51. package/src/tools/computer.ts +279 -269
  52. package/src/web/server.ts +35 -2
  53. package/tests/fence_plugin.test.ts +52 -0
  54. package/tests/loom.test.ts +89 -0
  55. package/tests/ssrf.test.ts +38 -0
  56. package/tsconfig.json +1 -0
@@ -1,66 +1,91 @@
1
- /**
2
- * Plugin loader — loads external plugins that register additional tools.
3
- */
4
-
5
- import * as fs from 'fs';
6
- import * as path from 'path';
7
- import { ToolRegistry } from '../core/tool';
8
- import { getLogger } from '../core/logger';
9
-
10
- const log = getLogger('plugin-loader');
11
-
12
- export class PluginLoader {
13
- private toolRegistry: ToolRegistry;
14
-
15
- constructor(toolRegistry: ToolRegistry) {
16
- this.toolRegistry = toolRegistry;
17
- }
18
-
19
- /**
20
- * Load plugins from specified directories.
21
- */
22
- loadFromDirectories(directories: string[]): number {
23
- let total = 0;
24
- for (const dir of directories) {
25
- total += this.loadDirectory(dir);
26
- }
27
- return total;
28
- }
29
-
30
- /**
31
- * Load a single plugin directory.
32
- */
33
- private loadDirectory(dir: string): number {
34
- if (!fs.existsSync(dir)) {
35
- log.debug('plugin_dir_not_found', { dir });
36
- return 0;
37
- }
38
-
39
- let count = 0;
40
- try {
41
- const entries = fs.readdirSync(dir);
42
- for (const entry of entries) {
43
- const pluginPath = path.join(dir, entry);
44
- if (!fs.statSync(pluginPath).isDirectory()) continue;
45
-
46
- const pluginFile = path.join(pluginPath, 'index.js');
47
- if (!fs.existsSync(pluginFile)) continue;
48
-
49
- try {
50
- const plugin = require(pluginFile);
51
- if (typeof plugin.register === 'function') {
52
- plugin.register(this.toolRegistry);
53
- count++;
54
- log.info('plugin_loaded', { name: entry });
55
- }
56
- } catch (e) {
57
- log.warn('plugin_load_failed', { name: entry, error: String(e) });
58
- }
59
- }
60
- } catch (e) {
61
- log.warn('plugin_dir_scan_failed', { dir, error: String(e) });
62
- }
63
-
64
- return count;
65
- }
66
- }
1
+ /**
2
+ * Plugin loader — loads external plugins that register additional tools.
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { ToolRegistry } from '../core/tool';
8
+ import { getLogger } from '../core/logger';
9
+
10
+ const log = getLogger('plugin-loader');
11
+
12
+ /**
13
+ * A plugin path is safe to `require` only if neither it nor (on POSIX) its
14
+ * permissions allow group/world write — otherwise a less-privileged user could
15
+ * drop code that runs in this process. Always safe on Windows (no POSIX bits);
16
+ * SKYLOOM_ALLOW_UNSAFE_PLUGINS=1 bypasses the check.
17
+ */
18
+ export function isSafePluginPath(target: string): boolean {
19
+ if (process.env.SKYLOOM_ALLOW_UNSAFE_PLUGINS === '1') return true;
20
+ if (process.platform === 'win32') return true;
21
+ try {
22
+ const mode = fs.statSync(target).mode;
23
+ return (mode & 0o022) === 0; // no group-write, no world-write
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ export class PluginLoader {
30
+ private toolRegistry: ToolRegistry;
31
+
32
+ constructor(toolRegistry: ToolRegistry) {
33
+ this.toolRegistry = toolRegistry;
34
+ }
35
+
36
+ /**
37
+ * Load plugins from specified directories.
38
+ */
39
+ loadFromDirectories(directories: string[]): number {
40
+ let total = 0;
41
+ for (const dir of directories) {
42
+ total += this.loadDirectory(dir);
43
+ }
44
+ return total;
45
+ }
46
+
47
+ /**
48
+ * Load a single plugin directory.
49
+ */
50
+ private loadDirectory(dir: string): number {
51
+ if (!fs.existsSync(dir)) {
52
+ log.debug('plugin_dir_not_found', { dir });
53
+ return 0;
54
+ }
55
+
56
+ let count = 0;
57
+ try {
58
+ const entries = fs.readdirSync(dir);
59
+ for (const entry of entries) {
60
+ const pluginPath = path.join(dir, entry);
61
+ if (!fs.statSync(pluginPath).isDirectory()) continue;
62
+
63
+ const pluginFile = path.join(pluginPath, 'index.js');
64
+ if (!fs.existsSync(pluginFile)) continue;
65
+
66
+ // Refuse to execute code from a group/world-writable plugin file or its
67
+ // directory — anyone who can write there would get arbitrary code
68
+ // execution in this process. Opt out with SKYLOOM_ALLOW_UNSAFE_PLUGINS=1.
69
+ if (!isSafePluginPath(pluginPath) || !isSafePluginPath(pluginFile)) {
70
+ log.warn('plugin_skipped_unsafe_perms', { name: entry });
71
+ continue;
72
+ }
73
+
74
+ try {
75
+ const plugin = require(pluginFile);
76
+ if (typeof plugin.register === 'function') {
77
+ plugin.register(this.toolRegistry);
78
+ count++;
79
+ log.info('plugin_loaded', { name: entry });
80
+ }
81
+ } catch (e) {
82
+ log.warn('plugin_load_failed', { name: entry, error: String(e) });
83
+ }
84
+ }
85
+ } catch (e) {
86
+ log.warn('plugin_dir_scan_failed', { dir, error: String(e) });
87
+ }
88
+
89
+ return count;
90
+ }
91
+ }
@@ -3,13 +3,91 @@
3
3
  */
4
4
 
5
5
  import * as fs from 'fs';
6
+ import * as os from 'os';
6
7
  import * as path from 'path';
8
+ import { lookup } from 'dns/promises';
7
9
  import type { ToolRegistry } from '../core/tool';
8
10
  import { getLogger } from '../core/logger';
9
11
  import { registerComputerTools } from './computer';
10
12
 
11
13
  const log = getLogger('builtin-tools');
12
14
 
15
+ /* ── SSRF guard for outbound fetches ──────────────────────────────────────
16
+ http_get is auto-approved (DangerLevel.LOW), so without this an agent or
17
+ prompt-injected content could pivot to internal services / cloud metadata
18
+ (169.254.169.254). We block private, loopback and link-local targets — both
19
+ when the URL is an IP literal and after DNS resolution. Operators who need to
20
+ reach internal hosts set SKYLOOM_ALLOW_PRIVATE_FETCH=1.
21
+ ────────────────────────────────────────────────────────────────────────── */
22
+ function isPrivateIPv4(ip: string): boolean {
23
+ const p = ip.split('.').map(Number);
24
+ if (p.length !== 4 || p.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
25
+ const [a, b] = p;
26
+ if (a === 0 || a === 127) return true; // this-host / loopback
27
+ if (a === 10) return true; // private
28
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
29
+ if (a === 192 && b === 168) return true; // private
30
+ if (a === 169 && b === 254) return true; // link-local + cloud metadata
31
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
32
+ return false;
33
+ }
34
+
35
+ function isPrivateIp(ip: string): boolean {
36
+ const v = ip.toLowerCase();
37
+ if (v === '::1' || v === '::') return true;
38
+ if (v.startsWith('::ffff:')) { // IPv4-mapped IPv6
39
+ const mapped = v.slice(7);
40
+ if (mapped.includes('.')) return isPrivateIPv4(mapped);
41
+ }
42
+ if (/^f[cd]/.test(v)) return true; // fc00::/7 unique-local
43
+ if (/^fe[89ab]/.test(v)) return true; // fe80::/10 link-local
44
+ if (v.includes('.') && !v.includes(':')) return isPrivateIPv4(v);
45
+ return false;
46
+ }
47
+
48
+ export { isPrivateIp, assertFetchAllowed }; // exported for tests
49
+
50
+ async function assertFetchAllowed(rawUrl: string): Promise<void> {
51
+ let u: URL;
52
+ try { u = new URL(rawUrl); } catch { throw new Error(`invalid URL: ${rawUrl}`); }
53
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
54
+ throw new Error(`blocked URL scheme '${u.protocol}' — only http/https are allowed`);
55
+ }
56
+ if (process.env.SKYLOOM_ALLOW_PRIVATE_FETCH === '1') return;
57
+ const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
58
+ if (isPrivateIp(host)) {
59
+ throw new Error(`blocked request to private/loopback address ${host} (set SKYLOOM_ALLOW_PRIVATE_FETCH=1 to allow)`);
60
+ }
61
+ let addrs: Array<{ address: string }> = [];
62
+ try { addrs = await lookup(host, { all: true }); } catch { return; /* let fetch surface DNS errors */ }
63
+ for (const a of addrs) {
64
+ if (isPrivateIp(a.address)) {
65
+ throw new Error(`blocked request: ${host} resolves to private address ${a.address} (set SKYLOOM_ALLOW_PRIVATE_FETCH=1 to allow)`);
66
+ }
67
+ }
68
+ }
69
+
70
+ /* ── Optional workspace fence for file tools ──────────────────────────────
71
+ Off by default (the agent is a Claude-Code-style assistant that legitimately
72
+ works across a repo). Set SKYLOOM_WORKSPACE_FENCE=1 to confine read/write/
73
+ edit/delete/list/search to a root directory (SKYLOOM_WORKSPACE_ROOT, or the
74
+ process cwd), blocking traversal to ~/.ssh, /etc, etc.
75
+ ────────────────────────────────────────────────────────────────────────── */
76
+ export function fenceRoot(): string | null {
77
+ if (process.env.SKYLOOM_WORKSPACE_FENCE !== '1') return null;
78
+ const raw = process.env.SKYLOOM_WORKSPACE_ROOT;
79
+ return raw ? path.resolve(raw.replace(/^~(?=$|\/|\\)/, os.homedir())) : process.cwd();
80
+ }
81
+
82
+ /** Returns an error string if `resolvedPath` is outside the fence, else null. */
83
+ export function fenceCheck(resolvedPath: string): string | null {
84
+ const root = fenceRoot();
85
+ if (!root) return null;
86
+ const rel = path.relative(root, resolvedPath);
87
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return null;
88
+ return `Error: 路径越界 — 工作区围栏已启用 (SKYLOOM_WORKSPACE_FENCE=1),'${resolvedPath}' 在根目录 '${root}' 之外。`;
89
+ }
90
+
13
91
  /**
14
92
  * Register all built-in tools into the given registry.
15
93
  */
@@ -28,6 +106,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
28
106
  ],
29
107
  handler: async (params) => {
30
108
  const filePath = path.resolve(params.path as string);
109
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
31
110
  if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
32
111
  try {
33
112
  const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
@@ -55,6 +134,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
55
134
  ],
56
135
  handler: async (params) => {
57
136
  const filePath = path.resolve(params.path as string);
137
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
58
138
  try {
59
139
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
60
140
  fs.writeFileSync(filePath, params.content as string, 'utf-8');
@@ -75,6 +155,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
75
155
  ],
76
156
  handler: async (params) => {
77
157
  const filePath = path.resolve(params.path as string);
158
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
78
159
  if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
79
160
  try {
80
161
  let content = fs.readFileSync(filePath, 'utf-8');
@@ -100,6 +181,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
100
181
  ],
101
182
  handler: async (params) => {
102
183
  const filePath = path.resolve(params.path as string);
184
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
103
185
  if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
104
186
  try {
105
187
  fs.unlinkSync(filePath);
@@ -118,6 +200,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
118
200
  ],
119
201
  handler: async (params) => {
120
202
  const dirPath = path.resolve(params.path as string);
203
+ const fenced = fenceCheck(dirPath); if (fenced) return fenced;
121
204
  if (!fs.existsSync(dirPath)) return `Error: Directory not found: ${dirPath}`;
122
205
  try {
123
206
  const entries = fs.readdirSync(dirPath);
@@ -140,6 +223,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
140
223
  ],
141
224
  handler: async (params) => {
142
225
  const dir = params.directory ? path.resolve(params.directory as string) : process.cwd();
226
+ const fenced = fenceCheck(dir); if (fenced) return fenced;
143
227
  const pattern = params.pattern as string;
144
228
  try {
145
229
  const { globSync } = require('glob');
@@ -183,11 +267,12 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
183
267
  ],
184
268
  handler: async (params) => {
185
269
  try {
270
+ await assertFetchAllowed(params.url as string);
186
271
  const response = await fetch(params.url as string);
187
272
  const text = await response.text();
188
273
  return `Status: ${response.status}\n\n${text.slice(0, 10000)}${text.length > 10000 ? '\n...[truncated]' : ''}`;
189
274
  } catch (e) {
190
- return `Error fetching URL: ${e}`;
275
+ return `Error fetching URL: ${e instanceof Error ? e.message : e}`;
191
276
  }
192
277
  },
193
278
  });
@@ -306,10 +391,10 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
306
391
  { name: 'max_count', type: 'number', description: 'Number of commits to show (default: 10)', required: false },
307
392
  ],
308
393
  handler: async (params) => {
309
- const { execSync } = require('child_process');
394
+ const { execFileSync } = require('child_process');
310
395
  try {
311
- const n = (params.max_count as number) || 10;
312
- return execSync(`git log --oneline -${n}`, { encoding: 'utf-8' });
396
+ const n = Math.max(1, Math.min(1000, Math.floor(Number(params.max_count) || 10)));
397
+ return execFileSync('git', ['log', '--oneline', `-${n}`], { encoding: 'utf-8' });
313
398
  } catch (e: any) {
314
399
  return `Error: ${e.message || e}`;
315
400
  }
@@ -323,10 +408,12 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
323
408
  { name: 'message', type: 'string', description: 'Commit message', required: true },
324
409
  ],
325
410
  handler: async (params) => {
326
- const { execSync } = require('child_process');
411
+ const { execFileSync } = require('child_process');
327
412
  try {
328
- const msg = params.message as string;
329
- execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { encoding: 'utf-8' });
413
+ const msg = String(params.message ?? '');
414
+ // execFileSync passes the message as a single argv entry — no shell, so
415
+ // backticks / $() / ; in the message cannot be interpreted.
416
+ execFileSync('git', ['commit', '-m', msg], { encoding: 'utf-8' });
330
417
  return 'Commit created successfully.';
331
418
  } catch (e: any) {
332
419
  return `Error: ${e.message || e}`;
@@ -345,15 +432,29 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
345
432
  { name: 'path', type: 'string', description: 'Directory to search in', required: false },
346
433
  ],
347
434
  handler: async (params) => {
348
- const { execSync } = require('child_process');
435
+ const { execFileSync } = require('child_process');
349
436
  const searchDir = params.path ? path.resolve(params.path as string) : process.cwd();
437
+ const fenced = fenceCheck(searchDir); if (fenced) return fenced;
350
438
  const pat = String(params.pattern || '');
351
- try {
352
- const out = execSync('rg -n ' + pat + ' ' + searchDir + ' 2>/dev/null || grep -rn ' + pat + ' ' + searchDir + ' 2>/dev/null || echo "No matches found"', { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
353
- return out;
354
- } catch {
355
- return 'No matches found.';
439
+ // No shell: pattern and directory are passed as argv entries, and `--`
440
+ // stops a leading `-` in the pattern from being read as a flag. This is a
441
+ // non-dangerous (auto-approved) tool, so shell injection here would have
442
+ // bypassed the tool-approval gate entirely.
443
+ const variants: [string, string[]][] = [
444
+ ['rg', ['-n', '--', pat, searchDir]],
445
+ ['grep', ['-rn', '--', pat, searchDir]],
446
+ ];
447
+ for (const [bin, args] of variants) {
448
+ try {
449
+ const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
450
+ return out || 'No matches found.';
451
+ } catch (e: any) {
452
+ // exit status 1 = ran successfully, zero matches; anything else
453
+ // (e.g. binary not installed) falls through to the next variant.
454
+ if (e?.status === 1) return 'No matches found.';
455
+ }
356
456
  }
457
+ return 'No matches found.';
357
458
  },
358
459
  });
359
460
 
@@ -365,11 +466,13 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
365
466
  { name: 'depth', type: 'number', description: 'Maximum depth (default: 3)', required: false },
366
467
  ],
367
468
  handler: async (params) => {
368
- const { execSync } = require('child_process');
469
+ const { execFileSync } = require('child_process');
369
470
  const treeDir = params.directory ? path.resolve(params.directory as string) : process.cwd();
370
- const depth = (params.depth as number) || 3;
471
+ const fenced = fenceCheck(treeDir); if (fenced) return fenced;
472
+ const depth = Math.max(1, Math.min(20, Math.floor(Number(params.depth) || 3)));
371
473
  try {
372
- const out = execSync('tree "' + treeDir + '" -L ' + depth + ' --charset=utf-8 2>/dev/null || echo "tree unavailable"', { encoding: 'utf-8' });
474
+ // No shell: directory passed as an argv entry, depth clamped to an int.
475
+ const out = execFileSync('tree', [treeDir, '-L', String(depth), '--charset=utf-8'], { encoding: 'utf-8' });
373
476
  return out;
374
477
  } catch {
375
478
  return 'Directory tree unavailable.';