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.
- package/dist/cli/loom.d.ts +2 -0
- package/dist/cli/loom.d.ts.map +1 -1
- package/dist/cli/loom.js +76 -7
- package/dist/cli/loom.js.map +1 -1
- package/dist/cli/main.js +12 -3
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent/guard.d.ts.map +1 -1
- package/dist/core/agent/guard.js +1 -2
- package/dist/core/agent/guard.js.map +1 -1
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +12 -9
- package/dist/core/agent.js.map +1 -1
- package/dist/core/agent_helpers.d.ts +3 -3
- package/dist/core/agent_helpers.d.ts.map +1 -1
- package/dist/core/agent_helpers.js +7 -3
- package/dist/core/agent_helpers.js.map +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +8 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/memory.d.ts.map +1 -1
- package/dist/core/memory.js +12 -2
- package/dist/core/memory.js.map +1 -1
- package/dist/core/model_config.d.ts.map +1 -1
- package/dist/core/model_config.js +7 -2
- package/dist/core/model_config.js.map +1 -1
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +27 -0
- package/dist/plugins/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts +6 -0
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +160 -17
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/computer.d.ts.map +1 -1
- package/dist/tools/computer.js +18 -7
- package/dist/tools/computer.js.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +35 -2
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/loom.ts +66 -7
- package/src/cli/main.ts +6 -3
- package/src/core/agent/guard.ts +1 -2
- package/src/core/agent.ts +12 -8
- package/src/core/agent_helpers.ts +7 -3
- package/src/core/config.ts +5 -2
- package/src/core/memory.ts +9 -2
- package/src/core/model_config.ts +4 -2
- package/src/plugins/loader.ts +91 -66
- package/src/tools/builtin.ts +119 -16
- package/src/tools/computer.ts +279 -269
- package/src/web/server.ts +35 -2
- package/tests/fence_plugin.test.ts +52 -0
- package/tests/loom.test.ts +89 -0
- package/tests/ssrf.test.ts +38 -0
- package/tsconfig.json +1 -0
package/src/plugins/loader.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
package/src/tools/builtin.ts
CHANGED
|
@@ -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 {
|
|
394
|
+
const { execFileSync } = require('child_process');
|
|
310
395
|
try {
|
|
311
|
-
const n = (params.max_count
|
|
312
|
-
return
|
|
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 {
|
|
411
|
+
const { execFileSync } = require('child_process');
|
|
327
412
|
try {
|
|
328
|
-
const msg = params.message
|
|
329
|
-
|
|
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 {
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 {
|
|
469
|
+
const { execFileSync } = require('child_process');
|
|
369
470
|
const treeDir = params.directory ? path.resolve(params.directory as string) : process.cwd();
|
|
370
|
-
const
|
|
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
|
-
|
|
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.';
|