@musashishao/folderforge 1.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/README.md +181 -0
- package/dist/adapters/child-mcp/client.js +114 -0
- package/dist/adapters/child-mcp/registry.js +66 -0
- package/dist/audit/audit-log.js +45 -0
- package/dist/audit/event-types.js +1 -0
- package/dist/core/config.js +211 -0
- package/dist/core/container.js +51 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/types.js +4 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/lsp/protocol.js +116 -0
- package/dist/main.js +190 -0
- package/dist/managers/db-manager.js +161 -0
- package/dist/managers/lsp-manager.js +269 -0
- package/dist/managers/process-manager.js +140 -0
- package/dist/policy/approvals.js +143 -0
- package/dist/policy/command-policy.js +99 -0
- package/dist/policy/glob-match.js +61 -0
- package/dist/policy/path-policy.js +73 -0
- package/dist/policy/policy-engine.js +156 -0
- package/dist/policy/rate-limiter.js +96 -0
- package/dist/policy/risk.js +112 -0
- package/dist/policy/secret-policy.js +132 -0
- package/dist/server/mcp-server.js +144 -0
- package/dist/server/transports/http.js +133 -0
- package/dist/server/transports/stdio.js +14 -0
- package/dist/tools/adapter-tools.js +62 -0
- package/dist/tools/browser-tools.js +76 -0
- package/dist/tools/build-tools.js +78 -0
- package/dist/tools/code-tools.js +250 -0
- package/dist/tools/coverage-tools.js +135 -0
- package/dist/tools/db-tools.js +130 -0
- package/dist/tools/diff-util.js +45 -0
- package/dist/tools/error-parser.js +57 -0
- package/dist/tools/file-tools.js +319 -0
- package/dist/tools/format-tools.js +118 -0
- package/dist/tools/git-tools.js +371 -0
- package/dist/tools/index.js +63 -0
- package/dist/tools/memory-tools.js +54 -0
- package/dist/tools/output-schemas.js +100 -0
- package/dist/tools/pagination.js +92 -0
- package/dist/tools/pkg-tools.js +260 -0
- package/dist/tools/process-tools.js +128 -0
- package/dist/tools/registry.js +194 -0
- package/dist/tools/schema-lock.js +152 -0
- package/dist/tools/search-tools.js +176 -0
- package/dist/tools/security-tools.js +147 -0
- package/dist/tools/terminal-tools.js +57 -0
- package/dist/tools/workspace-tools.js +186 -0
- package/dist/workspace/memory-store.js +67 -0
- package/dist/workspace/onboarding.js +46 -0
- package/dist/workspace/project-detector.js +95 -0
- package/dist/workspace/workspace-manager.js +106 -0
- package/docs/adapters.md +76 -0
- package/docs/architecture.md +66 -0
- package/docs/roadmap.md +172 -0
- package/docs/security.md +94 -0
- package/docs/tools.md +129 -0
- package/examples/claude-desktop.json +18 -0
- package/examples/codex.toml +18 -0
- package/examples/config.basic.yaml +37 -0
- package/examples/config.full.yaml +120 -0
- package/package.json +74 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { defineTool } from './registry.js';
|
|
5
|
+
import { detectProject } from '../workspace/project-detector.js';
|
|
6
|
+
import { parseErrors } from './error-parser.js';
|
|
7
|
+
/** Detect the package manager for a project root (lockfile-first). */
|
|
8
|
+
export function detectPackageManager(root) {
|
|
9
|
+
if (existsSync(join(root, 'pnpm-lock.yaml')))
|
|
10
|
+
return 'pnpm';
|
|
11
|
+
if (existsSync(join(root, 'yarn.lock')))
|
|
12
|
+
return 'yarn';
|
|
13
|
+
if (existsSync(join(root, 'package-lock.json')))
|
|
14
|
+
return 'npm';
|
|
15
|
+
if (existsSync(join(root, 'package.json')))
|
|
16
|
+
return 'npm';
|
|
17
|
+
if (existsSync(join(root, 'Cargo.toml')))
|
|
18
|
+
return 'cargo';
|
|
19
|
+
if (existsSync(join(root, 'go.mod')))
|
|
20
|
+
return 'go';
|
|
21
|
+
if (existsSync(join(root, 'pyproject.toml')) || existsSync(join(root, 'requirements.txt')))
|
|
22
|
+
return 'pip';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/** Map a package manager to its CLI verbs. `null` = unsupported for that op. */
|
|
26
|
+
function commandsFor(pm) {
|
|
27
|
+
switch (pm) {
|
|
28
|
+
case 'npm':
|
|
29
|
+
return {
|
|
30
|
+
list: ['npm', 'ls', '--depth=0'],
|
|
31
|
+
outdated: ['npm', 'outdated', '--json'],
|
|
32
|
+
audit: ['npm', 'audit', '--json'],
|
|
33
|
+
add: (p, dev) => ['npm', 'install', dev ? '--save-dev' : '--save', p],
|
|
34
|
+
remove: (p) => ['npm', 'uninstall', p],
|
|
35
|
+
run: (s) => ['npm', 'run', s],
|
|
36
|
+
};
|
|
37
|
+
case 'pnpm':
|
|
38
|
+
return {
|
|
39
|
+
list: ['pnpm', 'list', '--depth=0'],
|
|
40
|
+
outdated: ['pnpm', 'outdated', '--format', 'json'],
|
|
41
|
+
audit: ['pnpm', 'audit', '--json'],
|
|
42
|
+
add: (p, dev) => ['pnpm', 'add', ...(dev ? ['-D'] : []), p],
|
|
43
|
+
remove: (p) => ['pnpm', 'remove', p],
|
|
44
|
+
run: (s) => ['pnpm', 'run', s],
|
|
45
|
+
};
|
|
46
|
+
case 'yarn':
|
|
47
|
+
return {
|
|
48
|
+
list: ['yarn', 'list', '--depth=0'],
|
|
49
|
+
outdated: ['yarn', 'outdated', '--json'],
|
|
50
|
+
audit: ['yarn', 'audit', '--json'],
|
|
51
|
+
add: (p, dev) => ['yarn', 'add', ...(dev ? ['-D'] : []), p],
|
|
52
|
+
remove: (p) => ['yarn', 'remove', p],
|
|
53
|
+
run: (s) => ['yarn', 'run', s],
|
|
54
|
+
};
|
|
55
|
+
case 'pip':
|
|
56
|
+
return {
|
|
57
|
+
list: ['pip', 'list'],
|
|
58
|
+
outdated: ['pip', 'list', '--outdated'],
|
|
59
|
+
audit: ['pip-audit'], // optional tool; reports unavailable if missing
|
|
60
|
+
add: (p) => ['pip', 'install', p],
|
|
61
|
+
remove: (p) => ['pip', 'uninstall', '-y', p],
|
|
62
|
+
run: null,
|
|
63
|
+
};
|
|
64
|
+
case 'cargo':
|
|
65
|
+
return {
|
|
66
|
+
list: ['cargo', 'tree', '--depth', '1'],
|
|
67
|
+
outdated: ['cargo', 'outdated'],
|
|
68
|
+
audit: ['cargo', 'audit'],
|
|
69
|
+
add: (p, dev) => ['cargo', 'add', ...(dev ? ['--dev'] : []), p],
|
|
70
|
+
remove: (p) => ['cargo', 'remove', p],
|
|
71
|
+
run: null,
|
|
72
|
+
};
|
|
73
|
+
case 'go':
|
|
74
|
+
return {
|
|
75
|
+
list: ['go', 'list', '-m', 'all'],
|
|
76
|
+
outdated: ['go', 'list', '-m', '-u', 'all'],
|
|
77
|
+
audit: ['govulncheck', './...'],
|
|
78
|
+
add: (p) => ['go', 'get', p],
|
|
79
|
+
remove: (p) => ['go', 'get', `${p}@none`],
|
|
80
|
+
run: null,
|
|
81
|
+
};
|
|
82
|
+
default:
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** A package spec must look like a dependency name/version, not a shell payload. */
|
|
87
|
+
function validatePkgSpec(spec) {
|
|
88
|
+
// Allow scoped names, versions, extras, and git/url-free specs.
|
|
89
|
+
// Reject shell metacharacters to prevent argument-injection via the spec.
|
|
90
|
+
if (!spec || spec.length > 214)
|
|
91
|
+
return 'Package spec is empty or too long.';
|
|
92
|
+
if (/[;&|`$(){}<>\n\r\\]/.test(spec))
|
|
93
|
+
return 'Package spec contains illegal characters.';
|
|
94
|
+
if (/^\s|\s$/.test(spec))
|
|
95
|
+
return 'Package spec has leading/trailing whitespace.';
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
async function runPm(ctx, argv) {
|
|
99
|
+
const [bin, ...rest] = argv;
|
|
100
|
+
const sub = await execa(bin, rest, {
|
|
101
|
+
cwd: ctx.projectRoot,
|
|
102
|
+
timeout: ctx.config.terminal.defaultTimeoutMs,
|
|
103
|
+
reject: false,
|
|
104
|
+
maxBuffer: ctx.config.terminal.maxOutputBytes * 4,
|
|
105
|
+
});
|
|
106
|
+
if (sub.exitCode === undefined && sub.failed && /ENOENT/.test(String(sub.shortMessage ?? ''))) {
|
|
107
|
+
return { ok: false, error: `Command not found: ${bin}. Install it or pick another package manager.` };
|
|
108
|
+
}
|
|
109
|
+
const max = ctx.config.terminal.maxOutputBytes;
|
|
110
|
+
const redact = ctx.container.policy.secret.redact;
|
|
111
|
+
const stdout = redact((sub.stdout ?? '').slice(0, max));
|
|
112
|
+
const stderr = redact((sub.stderr ?? '').slice(0, max));
|
|
113
|
+
return {
|
|
114
|
+
ok: sub.exitCode === 0,
|
|
115
|
+
data: { command: argv.join(' '), exitCode: sub.exitCode ?? null, stdout, stderr },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function withPm(build) {
|
|
119
|
+
return async (ctx) => {
|
|
120
|
+
const pm = detectPackageManager(ctx.projectRoot);
|
|
121
|
+
if (!pm) {
|
|
122
|
+
return { ok: false, error: 'No package manager detected (no package.json/pyproject/Cargo/go.mod).' };
|
|
123
|
+
}
|
|
124
|
+
const cmds = commandsFor(pm);
|
|
125
|
+
if (!cmds)
|
|
126
|
+
return { ok: false, error: `Unsupported package manager: ${pm}` };
|
|
127
|
+
const argv = build(cmds, pm);
|
|
128
|
+
if (!argv)
|
|
129
|
+
return { ok: false, error: `Operation not supported for ${pm}.` };
|
|
130
|
+
return { argv };
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function pkgTools() {
|
|
134
|
+
return [
|
|
135
|
+
defineTool({
|
|
136
|
+
name: 'pkg_list',
|
|
137
|
+
description: 'List installed top-level dependencies (auto-detects the package manager).',
|
|
138
|
+
group: 'pkg',
|
|
139
|
+
mutates: false,
|
|
140
|
+
inputSchema: { type: 'object', properties: {} },
|
|
141
|
+
handler: async (_a, ctx) => {
|
|
142
|
+
const r = await withPm((c) => c.list)(ctx);
|
|
143
|
+
if ('ok' in r)
|
|
144
|
+
return r;
|
|
145
|
+
return runPm(ctx, r.argv);
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
defineTool({
|
|
149
|
+
name: 'pkg_outdated',
|
|
150
|
+
description: 'Report dependencies that have newer versions available.',
|
|
151
|
+
group: 'pkg',
|
|
152
|
+
mutates: false,
|
|
153
|
+
inputSchema: { type: 'object', properties: {} },
|
|
154
|
+
handler: async (_a, ctx) => {
|
|
155
|
+
const r = await withPm((c) => c.outdated)(ctx);
|
|
156
|
+
if ('ok' in r)
|
|
157
|
+
return r;
|
|
158
|
+
return runPm(ctx, r.argv);
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
defineTool({
|
|
162
|
+
name: 'pkg_audit',
|
|
163
|
+
description: 'Scan dependencies for known vulnerabilities.',
|
|
164
|
+
group: 'pkg',
|
|
165
|
+
mutates: false,
|
|
166
|
+
inputSchema: { type: 'object', properties: {} },
|
|
167
|
+
handler: async (_a, ctx) => {
|
|
168
|
+
const r = await withPm((c) => c.audit)(ctx);
|
|
169
|
+
if ('ok' in r)
|
|
170
|
+
return r;
|
|
171
|
+
return runPm(ctx, r.argv);
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
defineTool({
|
|
175
|
+
name: 'pkg_run',
|
|
176
|
+
description: 'Run a script defined in the project manifest (e.g. package.json scripts).',
|
|
177
|
+
group: 'pkg',
|
|
178
|
+
mutates: true,
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: { script: { type: 'string', description: 'Script name as declared in the manifest.' } },
|
|
182
|
+
required: ['script'],
|
|
183
|
+
},
|
|
184
|
+
handler: async (args, ctx) => {
|
|
185
|
+
const script = String(args.script ?? '');
|
|
186
|
+
const bad = validatePkgSpec(script);
|
|
187
|
+
if (bad)
|
|
188
|
+
return { ok: false, error: bad };
|
|
189
|
+
// Guard: only allow scripts that actually exist in package.json.
|
|
190
|
+
const proj = detectProject(ctx.projectRoot);
|
|
191
|
+
if (proj.packageManagers.some((p) => ['npm', 'pnpm', 'yarn'].includes(p))) {
|
|
192
|
+
try {
|
|
193
|
+
const pkg = JSON.parse(readFileSync(join(ctx.projectRoot, 'package.json'), 'utf8'));
|
|
194
|
+
if (!pkg.scripts || !(script in pkg.scripts)) {
|
|
195
|
+
return { ok: false, error: `Script "${script}" is not defined in package.json.` };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* fall through to attempt run */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const r = await withPm((c) => (c.run ? c.run(script) : null))(ctx);
|
|
203
|
+
if ('ok' in r)
|
|
204
|
+
return r;
|
|
205
|
+
const res = await runPm(ctx, r.argv);
|
|
206
|
+
if (res.ok && res.data) {
|
|
207
|
+
const d = res.data;
|
|
208
|
+
res.data.errors = parseErrors(`${d.stdout ?? ''}\n${d.stderr ?? ''}`);
|
|
209
|
+
}
|
|
210
|
+
return res;
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
defineTool({
|
|
214
|
+
name: 'pkg_add',
|
|
215
|
+
description: 'Add a dependency. HIGH risk; mutates the dependency tree (requires approval per policy).',
|
|
216
|
+
group: 'pkg',
|
|
217
|
+
mutates: true,
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: 'object',
|
|
220
|
+
properties: {
|
|
221
|
+
package: { type: 'string', description: 'Dependency spec, e.g. "lodash" or "lodash@4".' },
|
|
222
|
+
dev: { type: 'boolean', description: 'Install as a dev dependency.' },
|
|
223
|
+
},
|
|
224
|
+
required: ['package'],
|
|
225
|
+
},
|
|
226
|
+
handler: async (args, ctx) => {
|
|
227
|
+
const spec = String(args.package ?? '');
|
|
228
|
+
const bad = validatePkgSpec(spec);
|
|
229
|
+
if (bad)
|
|
230
|
+
return { ok: false, error: bad };
|
|
231
|
+
const dev = Boolean(args.dev);
|
|
232
|
+
const r = await withPm((c) => c.add(spec, dev))(ctx);
|
|
233
|
+
if ('ok' in r)
|
|
234
|
+
return r;
|
|
235
|
+
return runPm(ctx, r.argv);
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
defineTool({
|
|
239
|
+
name: 'pkg_remove',
|
|
240
|
+
description: 'Remove a dependency. HIGH risk; mutates the dependency tree (requires approval per policy).',
|
|
241
|
+
group: 'pkg',
|
|
242
|
+
mutates: true,
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
properties: { package: { type: 'string' } },
|
|
246
|
+
required: ['package'],
|
|
247
|
+
},
|
|
248
|
+
handler: async (args, ctx) => {
|
|
249
|
+
const spec = String(args.package ?? '');
|
|
250
|
+
const bad = validatePkgSpec(spec);
|
|
251
|
+
if (bad)
|
|
252
|
+
return { ok: false, error: bad };
|
|
253
|
+
const r = await withPm((c) => c.remove(spec))(ctx);
|
|
254
|
+
if ('ok' in r)
|
|
255
|
+
return r;
|
|
256
|
+
return runPm(ctx, r.argv);
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { defineTool } from './registry.js';
|
|
2
|
+
export function processTools() {
|
|
3
|
+
return [
|
|
4
|
+
defineTool({
|
|
5
|
+
name: 'process_start',
|
|
6
|
+
description: 'Start a long-running process (dev server, watcher) and return a session id.',
|
|
7
|
+
group: 'process',
|
|
8
|
+
mutates: true,
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: { command: { type: 'string' }, cwd: { type: 'string' } },
|
|
12
|
+
required: ['command'],
|
|
13
|
+
},
|
|
14
|
+
handler: async (args, ctx) => {
|
|
15
|
+
const command = String(args.command);
|
|
16
|
+
const cls = ctx.container.policy.command.classify(command);
|
|
17
|
+
if (cls.risk === 'CRITICAL' && ctx.container.policy.getMode() !== 'danger') {
|
|
18
|
+
return { ok: false, error: `Blocked destructive command: ${cls.blockedReason ?? command}` };
|
|
19
|
+
}
|
|
20
|
+
const cwd = args.cwd
|
|
21
|
+
? ctx.container.policy.path.resolveSafe(String(args.cwd), ctx.projectRoot)
|
|
22
|
+
: ctx.projectRoot;
|
|
23
|
+
const session = ctx.container.processes.start(command, cwd, ctx.config.terminal.shell);
|
|
24
|
+
ctx.container.audit.record({ type: 'process_event', summary: `start ${session.sessionId}: ${command}` });
|
|
25
|
+
return { ok: true, data: session };
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
defineTool({
|
|
29
|
+
name: 'process_read',
|
|
30
|
+
description: 'Read new output from a process session since the last cursor.',
|
|
31
|
+
group: 'process',
|
|
32
|
+
mutates: false,
|
|
33
|
+
inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] },
|
|
34
|
+
handler: async (args, ctx) => {
|
|
35
|
+
const out = ctx.container.processes.read(String(args.sessionId));
|
|
36
|
+
return { ok: true, data: { ...out, output: ctx.container.policy.secret.redact(out.output) } };
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
defineTool({
|
|
40
|
+
name: 'process_tail',
|
|
41
|
+
description: 'Stream output from a process session: blocks until new output arrives, the ' +
|
|
42
|
+
'process exits, or timeoutMs elapses (default 2000ms). Call repeatedly to ' +
|
|
43
|
+
'follow a long-running command. `done` is true once the process has exited ' +
|
|
44
|
+
'and all output has been drained.',
|
|
45
|
+
group: 'process',
|
|
46
|
+
mutates: false,
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
sessionId: { type: 'string' },
|
|
51
|
+
timeoutMs: { type: 'number', description: 'Max ms to wait for new output (default 2000).' },
|
|
52
|
+
},
|
|
53
|
+
required: ['sessionId'],
|
|
54
|
+
},
|
|
55
|
+
handler: async (args, ctx) => {
|
|
56
|
+
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 2000), 0), 30000);
|
|
57
|
+
const signal = ctx.control?.signal;
|
|
58
|
+
const out = await ctx.container.processes.readUntil(String(args.sessionId), timeoutMs, signal);
|
|
59
|
+
// P4 - progress: report a tick each tail cycle so a client following a
|
|
60
|
+
// long-running command sees liveness without parsing the buffer. The
|
|
61
|
+
// total is unknown for an open-ended stream, so we omit it and send a
|
|
62
|
+
// status message instead.
|
|
63
|
+
await ctx.control?.reportProgress?.(out.cursor, undefined, out.done ? 'process exited' : `streaming (${out.status})`);
|
|
64
|
+
if (signal?.aborted && !out.done) {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
data: {
|
|
68
|
+
...out,
|
|
69
|
+
output: ctx.container.policy.secret.redact(out.output),
|
|
70
|
+
cancelled: true,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, data: { ...out, output: ctx.container.policy.secret.redact(out.output) } };
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
defineTool({
|
|
78
|
+
name: 'process_write',
|
|
79
|
+
description: 'Send a line of input to a running process session.',
|
|
80
|
+
group: 'process',
|
|
81
|
+
mutates: true,
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: { sessionId: { type: 'string' }, input: { type: 'string' } },
|
|
85
|
+
required: ['sessionId', 'input'],
|
|
86
|
+
},
|
|
87
|
+
handler: async (args, ctx) => {
|
|
88
|
+
ctx.container.processes.write(String(args.sessionId), String(args.input));
|
|
89
|
+
return { ok: true, data: { sent: true } };
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
defineTool({
|
|
93
|
+
name: 'process_stop',
|
|
94
|
+
description: 'Stop a process session gracefully (SIGTERM).',
|
|
95
|
+
group: 'process',
|
|
96
|
+
mutates: true,
|
|
97
|
+
inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] },
|
|
98
|
+
handler: async (args, ctx) => {
|
|
99
|
+
const s = ctx.container.processes.stop(String(args.sessionId));
|
|
100
|
+
ctx.container.audit.record({ type: 'process_event', summary: `stop ${s.sessionId}` });
|
|
101
|
+
return { ok: true, data: s };
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
defineTool({
|
|
105
|
+
name: 'process_kill',
|
|
106
|
+
description: 'Force-kill a process session (SIGKILL). HIGH risk; requires approval.',
|
|
107
|
+
group: 'process',
|
|
108
|
+
mutates: true,
|
|
109
|
+
inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] },
|
|
110
|
+
handler: async (args, ctx) => {
|
|
111
|
+
const id = String(args.sessionId);
|
|
112
|
+
if (!ctx.container.processes.isManaged(id)) {
|
|
113
|
+
return { ok: false, error: 'Refusing to kill a process not started by FolderForge.' };
|
|
114
|
+
}
|
|
115
|
+
const s = ctx.container.processes.kill(id);
|
|
116
|
+
return { ok: true, data: s };
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
defineTool({
|
|
120
|
+
name: 'process_list',
|
|
121
|
+
description: 'List process sessions managed by FolderForge.',
|
|
122
|
+
group: 'process',
|
|
123
|
+
mutates: false,
|
|
124
|
+
inputSchema: { type: 'object', properties: {} },
|
|
125
|
+
handler: async (_args, ctx) => ({ ok: true, data: { sessions: ctx.container.processes.list() } }),
|
|
126
|
+
}),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { TOOL_RISK, RISK_ORDER } from '../policy/risk.js';
|
|
2
|
+
import { ApprovalRequiredError, PolicyDeniedError } from '../core/errors.js';
|
|
3
|
+
import { logger } from '../core/logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Derive MCP tool annotations from the existing `mutates` / `risk` contract.
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally a pure mapping so annotations stay in lock-step with
|
|
8
|
+
* the frozen schema and never need to be hand-maintained:
|
|
9
|
+
* - `mutates === false` => readOnlyHint: true
|
|
10
|
+
* - risk HIGH or CRITICAL => destructiveHint: true (mutating only)
|
|
11
|
+
* - read-only tools => idempotentHint: true
|
|
12
|
+
* `openWorldHint` is opt-in per tool (e.g. web/http/browser) since most tools
|
|
13
|
+
* act only on the local workspace; callers may pass an override.
|
|
14
|
+
*/
|
|
15
|
+
export function deriveAnnotations(name, mutates, risk, override) {
|
|
16
|
+
const destructive = mutates && RISK_ORDER[risk] >= RISK_ORDER.HIGH;
|
|
17
|
+
const annotations = {
|
|
18
|
+
title: titleCase(name),
|
|
19
|
+
readOnlyHint: !mutates,
|
|
20
|
+
destructiveHint: destructive,
|
|
21
|
+
idempotentHint: !mutates,
|
|
22
|
+
openWorldHint: false,
|
|
23
|
+
...override,
|
|
24
|
+
};
|
|
25
|
+
return annotations;
|
|
26
|
+
}
|
|
27
|
+
function titleCase(name) {
|
|
28
|
+
return name
|
|
29
|
+
.split('_')
|
|
30
|
+
.map((p) => (p ? (p[0] ?? '').toUpperCase() + p.slice(1) : p))
|
|
31
|
+
.join(' ');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Helper to declare a tool with sensible defaults.
|
|
35
|
+
*/
|
|
36
|
+
export function defineTool(def) {
|
|
37
|
+
const risk = def.risk ?? TOOL_RISK[def.name] ?? 'MEDIUM';
|
|
38
|
+
return {
|
|
39
|
+
name: def.name,
|
|
40
|
+
description: def.description,
|
|
41
|
+
inputSchema: def.inputSchema,
|
|
42
|
+
...(def.outputSchema !== undefined ? { outputSchema: def.outputSchema } : {}),
|
|
43
|
+
group: def.group,
|
|
44
|
+
mutates: def.mutates,
|
|
45
|
+
risk,
|
|
46
|
+
annotations: deriveAnnotations(def.name, def.mutates, risk, def.annotations),
|
|
47
|
+
handler: def.handler,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Central registry. Holds every tool, computes the curated active subset,
|
|
52
|
+
* and wraps each call with policy evaluation + audit.
|
|
53
|
+
*/
|
|
54
|
+
export class ToolRegistry {
|
|
55
|
+
container;
|
|
56
|
+
tools = new Map();
|
|
57
|
+
activeSet = null;
|
|
58
|
+
constructor(container) {
|
|
59
|
+
this.container = container;
|
|
60
|
+
}
|
|
61
|
+
register(tool) {
|
|
62
|
+
this.tools.set(tool.name, tool);
|
|
63
|
+
}
|
|
64
|
+
registerAll(tools) {
|
|
65
|
+
for (const t of tools)
|
|
66
|
+
this.register(t);
|
|
67
|
+
}
|
|
68
|
+
get(name) {
|
|
69
|
+
return this.tools.get(name);
|
|
70
|
+
}
|
|
71
|
+
/** Restrict the visible tool set (routing). Pass null to show all. */
|
|
72
|
+
setActive(names) {
|
|
73
|
+
this.activeSet = names ? new Set(names) : null;
|
|
74
|
+
}
|
|
75
|
+
listActive() {
|
|
76
|
+
const all = [...this.tools.values()];
|
|
77
|
+
if (!this.activeSet)
|
|
78
|
+
return all;
|
|
79
|
+
return all.filter((t) => this.activeSet.has(t.name));
|
|
80
|
+
}
|
|
81
|
+
listAll() {
|
|
82
|
+
return [...this.tools.values()];
|
|
83
|
+
}
|
|
84
|
+
/** Execute a tool through the policy + audit pipeline. */
|
|
85
|
+
async call(name, rawArgs, control) {
|
|
86
|
+
const tool = this.tools.get(name);
|
|
87
|
+
if (!tool) {
|
|
88
|
+
return { ok: false, error: `Unknown tool: ${name}` };
|
|
89
|
+
}
|
|
90
|
+
const args = rawArgs ?? {};
|
|
91
|
+
const started = Date.now();
|
|
92
|
+
// P6 - cancellation: if the client already cancelled before we start (or
|
|
93
|
+
// cancels during the synchronous policy/rate-limit checks below), refuse
|
|
94
|
+
// early instead of doing work the caller no longer wants.
|
|
95
|
+
if (control?.signal?.aborted) {
|
|
96
|
+
return { ok: false, error: 'Tool call cancelled before execution.' };
|
|
97
|
+
}
|
|
98
|
+
// Determine per-call risk (shell can be re-classified by the handler later,
|
|
99
|
+
// but we evaluate the base risk here too).
|
|
100
|
+
let risk = tool.risk;
|
|
101
|
+
if (name === 'shell_exec' && typeof args.command === 'string') {
|
|
102
|
+
const cls = this.container.policy.command.classify(args.command);
|
|
103
|
+
risk = cls.risk;
|
|
104
|
+
}
|
|
105
|
+
this.container.audit.record({
|
|
106
|
+
type: 'tool_call',
|
|
107
|
+
tool: name,
|
|
108
|
+
risk,
|
|
109
|
+
summary: summarizeArgs(name, args),
|
|
110
|
+
});
|
|
111
|
+
const decision = this.container.policy.evaluate(name, risk, tool.mutates, args);
|
|
112
|
+
if (decision.kind === 'deny') {
|
|
113
|
+
this.container.audit.record({ type: 'policy_deny', tool: name, risk, summary: decision.reason });
|
|
114
|
+
return { ok: false, error: `Denied: ${decision.reason}` };
|
|
115
|
+
}
|
|
116
|
+
if (decision.kind === 'approval') {
|
|
117
|
+
this.container.audit.record({
|
|
118
|
+
type: 'approval_request',
|
|
119
|
+
tool: name,
|
|
120
|
+
risk,
|
|
121
|
+
summary: decision.reason,
|
|
122
|
+
detail: { approvalId: decision.approvalId },
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
approvalId: decision.approvalId,
|
|
127
|
+
error: `Approval required (${risk}). Resolve in the dashboard or via approval tools. id=${decision.approvalId}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Rate limit / quota: applied only to calls that policy would actually
|
|
131
|
+
// run. Denied or approval-gated calls never consume quota.
|
|
132
|
+
const rl = this.container.rateLimiter.hit(name);
|
|
133
|
+
if (!rl.allowed) {
|
|
134
|
+
this.container.audit.record({
|
|
135
|
+
type: 'rate_limited',
|
|
136
|
+
tool: name,
|
|
137
|
+
risk,
|
|
138
|
+
summary: rl.reason ?? 'rate limited',
|
|
139
|
+
detail: { retryAfterMs: rl.retryAfterMs, windowCount: rl.windowCount, dailyCount: rl.dailyCount },
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: `${rl.reason} Retry in ~${Math.ceil((rl.retryAfterMs ?? 0) / 1000)}s.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const result = await tool.handler(args, {
|
|
148
|
+
config: this.container.config,
|
|
149
|
+
projectRoot: this.container.projectRoot(),
|
|
150
|
+
...(control !== undefined ? { control } : {}),
|
|
151
|
+
container: this.container,
|
|
152
|
+
});
|
|
153
|
+
this.container.audit.record({
|
|
154
|
+
type: result.ok ? 'tool_result' : 'tool_error',
|
|
155
|
+
tool: name,
|
|
156
|
+
risk,
|
|
157
|
+
ok: result.ok,
|
|
158
|
+
durationMs: Date.now() - started,
|
|
159
|
+
summary: result.ok ? 'ok' : result.error ?? 'error',
|
|
160
|
+
});
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const message = err instanceof ApprovalRequiredError
|
|
165
|
+
? `Approval required: ${err.message} (id=${err.approvalId})`
|
|
166
|
+
: err instanceof PolicyDeniedError
|
|
167
|
+
? `Denied: ${err.message}`
|
|
168
|
+
: err instanceof Error
|
|
169
|
+
? err.message
|
|
170
|
+
: String(err);
|
|
171
|
+
logger.error({ tool: name, err: message }, 'tool error');
|
|
172
|
+
this.container.audit.record({
|
|
173
|
+
type: 'tool_error',
|
|
174
|
+
tool: name,
|
|
175
|
+
risk,
|
|
176
|
+
ok: false,
|
|
177
|
+
durationMs: Date.now() - started,
|
|
178
|
+
summary: message,
|
|
179
|
+
});
|
|
180
|
+
return { ok: false, error: message };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function summarizeArgs(name, args) {
|
|
185
|
+
const keys = Object.keys(args);
|
|
186
|
+
if (keys.length === 0)
|
|
187
|
+
return name;
|
|
188
|
+
const parts = keys.slice(0, 4).map((k) => {
|
|
189
|
+
const v = args[k];
|
|
190
|
+
const s = typeof v === 'string' ? v.slice(0, 60) : JSON.stringify(v);
|
|
191
|
+
return `${k}=${s}`;
|
|
192
|
+
});
|
|
193
|
+
return parts.join(' ');
|
|
194
|
+
}
|