@causyne/agent 1.10.12 → 1.10.17

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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/docs/ARCHITECTURE.md +136 -7
  3. package/docs/SECURITY_REVIEW.md +13 -1
  4. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  5. package/package.json +11 -4
  6. package/src/agentLoop.js +161 -93
  7. package/src/agentProfiles.js +135 -0
  8. package/src/agentProfiles.ts +143 -0
  9. package/src/approvalGrants.js +17 -35
  10. package/src/approvalGrants.ts +54 -0
  11. package/src/auditLog.d.ts +3 -0
  12. package/src/benchmarks/planCache.js +159 -0
  13. package/src/browser/camofoxRuntime.js +93 -0
  14. package/src/browser/camofoxRuntime.ts +112 -0
  15. package/src/browser/causyneBrowser.js +495 -0
  16. package/src/browser/causyneBrowser.ts +594 -0
  17. package/src/browser/searchProviders.js +156 -0
  18. package/src/browser/searchProviders.ts +156 -0
  19. package/src/browser/snapshotWindow.js +28 -0
  20. package/src/browser/snapshotWindow.ts +40 -0
  21. package/src/browser/urlPolicy.js +89 -0
  22. package/src/browser/urlPolicy.ts +89 -0
  23. package/src/checkpoints.js +19 -6
  24. package/src/commands.js +120 -8
  25. package/src/config.d.ts +2 -0
  26. package/src/cortex/autoCondenser.js +1 -1
  27. package/src/hooks.js +150 -0
  28. package/src/hooks.ts +193 -0
  29. package/src/mcp/mcpClient.js +520 -132
  30. package/src/mcp/mcpClient.ts +584 -0
  31. package/src/mcp/mcpRegistry.js +399 -185
  32. package/src/mcp/mcpRegistry.ts +451 -0
  33. package/src/planCache.js +529 -225
  34. package/src/planCache.ts +840 -0
  35. package/src/plugins/pluginManager.js +460 -0
  36. package/src/plugins/pluginRunner.js +50 -0
  37. package/src/plugins/pluginSkills.js +84 -0
  38. package/src/plugins/pluginSkills.ts +106 -0
  39. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  40. package/src/provisioning/capabilityProvisioner.js +5 -0
  41. package/src/provisioning/capabilityTools.js +2 -1
  42. package/src/sandbox.js +53 -2
  43. package/src/sessionJournal.js +471 -0
  44. package/src/sessionJournal.ts +496 -0
  45. package/src/sessionStore.js +3 -279
  46. package/src/skillsManager.js +18 -0
  47. package/src/subagentJournal.js +215 -0
  48. package/src/subagentJournal.ts +254 -0
  49. package/src/taskShape.js +179 -198
  50. package/src/taskShape.ts +259 -0
  51. package/src/toolPacks.js +6 -3
  52. package/src/tools/browserAutomator.js +45 -241
  53. package/src/tools/browserAutomator.ts +57 -0
  54. package/src/tools/deferredMcpSearch.js +2 -2
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/pluginManager.js +61 -0
  57. package/src/tools/registry.js +47 -53
  58. package/src/tools/subagentOrchestrator.js +73 -12
  59. package/src/tools/toolForge.js +47 -95
  60. package/src/tools/webFetch.js +183 -68
  61. package/src/tools/webFetch.ts +185 -0
  62. package/src/tools/webSearch.js +50 -75
  63. package/src/tools/webSearch.ts +56 -0
  64. package/src/types/contracts.js +8 -0
  65. package/src/types/contracts.ts +263 -0
  66. package/src/utils/paths.d.ts +3 -0
  67. package/src/version.d.ts +1 -0
  68. package/src/mcp/jsonRpc.js +0 -165
@@ -0,0 +1,460 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { causyneHome } from '../utils/paths.js';
7
+ import { parsePluginSkills } from './pluginSkills.js';
8
+
9
+ const PLUGIN_ROOT = path.join(causyneHome(), 'plugins');
10
+ const STATE_FILE = path.join(PLUGIN_ROOT, 'state.json');
11
+ const RUNNER_FILE = fileURLToPath(new URL('./pluginRunner.js', import.meta.url));
12
+ const PACKAGE_FILE = fileURLToPath(new URL('../../package.json', import.meta.url));
13
+ const MAX_MANIFEST_BYTES = 64 * 1024;
14
+ const MAX_ENTRY_BYTES = 512 * 1024;
15
+ const MAX_PLUGIN_BYTES = 2 * 1024 * 1024;
16
+ const MAX_PLUGIN_FILES = 128;
17
+ const MAX_TOOLS_PER_PLUGIN = 32;
18
+ const MAX_RESULT_BYTES = 1024 * 1024;
19
+ const DEFAULT_TIMEOUT_MS = 30_000;
20
+ const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
21
+ const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
22
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
23
+ const CAPABILITIES = new Set([
24
+ 'workspace:read',
25
+ 'workspace:write',
26
+ 'network',
27
+ 'subprocess'
28
+ ]);
29
+
30
+ function ensureRoot() {
31
+ if (!fs.existsSync(PLUGIN_ROOT)) fs.mkdirSync(PLUGIN_ROOT, { recursive: true, mode: 0o700 });
32
+ }
33
+
34
+ function safeObject(value) {
35
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
36
+ }
37
+
38
+ function readState() {
39
+ try {
40
+ if (!fs.existsSync(STATE_FILE)) return { version: 1, plugins: {} };
41
+ const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
42
+ return { version: 1, plugins: safeObject(parsed.plugins) };
43
+ } catch (error) {
44
+ return { version: 1, plugins: {} };
45
+ }
46
+ }
47
+
48
+ function writeState(state) {
49
+ ensureRoot();
50
+ const temporary = `${STATE_FILE}.tmp_${process.pid}`;
51
+ fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
52
+ fs.renameSync(temporary, STATE_FILE);
53
+ }
54
+
55
+ function resolveInside(root, relativePath) {
56
+ const resolvedRoot = path.resolve(root);
57
+ const resolved = path.resolve(root, String(relativePath || ''));
58
+ if (resolved === resolvedRoot || !resolved.startsWith(resolvedRoot + path.sep)) {
59
+ throw new Error(`Path escapes plugin root: ${relativePath}`);
60
+ }
61
+ return resolved;
62
+ }
63
+
64
+ function regularFile(file, maxBytes) {
65
+ const stat = fs.lstatSync(file);
66
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} must be a regular file`);
67
+ if (stat.size > maxBytes) throw new Error(`${file} exceeds ${Math.round(maxBytes / 1024)} KB`);
68
+ return stat;
69
+ }
70
+
71
+ function digestPluginDirectory(directory) {
72
+ const files = [];
73
+ const visit = (current) => {
74
+ const entries = fs.readdirSync(current, { withFileTypes: true })
75
+ .sort((left, right) => left.name.localeCompare(right.name));
76
+ for (const entry of entries) {
77
+ const absolute = path.join(current, entry.name);
78
+ const stat = fs.lstatSync(absolute);
79
+ if (stat.isSymbolicLink()) throw new Error(`Plugin content must not be a symlink: ${entry.name}`);
80
+ if (stat.isDirectory()) {
81
+ visit(absolute);
82
+ continue;
83
+ }
84
+ if (!stat.isFile()) throw new Error(`Plugin content must be a regular file: ${entry.name}`);
85
+ files.push({ absolute, relative: path.relative(directory, absolute), size: stat.size });
86
+ if (files.length > MAX_PLUGIN_FILES) throw new Error(`Plugin exceeds ${MAX_PLUGIN_FILES} files`);
87
+ }
88
+ };
89
+ visit(directory);
90
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
91
+ if (totalBytes > MAX_PLUGIN_BYTES) throw new Error('Plugin exceeds 2 MB');
92
+
93
+ const digest = crypto.createHash('sha256');
94
+ for (const file of files) {
95
+ digest.update(file.relative);
96
+ digest.update('\0');
97
+ digest.update(fs.readFileSync(file.absolute));
98
+ digest.update('\0');
99
+ }
100
+ return digest.digest('hex');
101
+ }
102
+
103
+ function validateCapabilities(values, label) {
104
+ if (values == null) return [];
105
+ if (!Array.isArray(values)) throw new Error(`${label} capabilities must be an array`);
106
+ const unique = [];
107
+ for (const value of values) {
108
+ if (!CAPABILITIES.has(value)) throw new Error(`${label} requests unknown capability ${JSON.stringify(value)}`);
109
+ if (!unique.includes(value)) unique.push(value);
110
+ }
111
+ if (unique.includes('workspace:write') && !unique.includes('workspace:read')) unique.unshift('workspace:read');
112
+ return unique;
113
+ }
114
+
115
+ function readPlugin(directory) {
116
+ const directoryStat = fs.lstatSync(directory);
117
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
118
+ throw new Error('Plugin directory must not be a symlink');
119
+ }
120
+
121
+ const manifestFile = path.join(directory, 'plugin.json');
122
+ regularFile(manifestFile, MAX_MANIFEST_BYTES);
123
+ const raw = fs.readFileSync(manifestFile, 'utf8');
124
+ const manifest = JSON.parse(raw);
125
+ const folderName = path.basename(directory);
126
+
127
+ if (manifest.schemaVersion !== 1) throw new Error('schemaVersion must be 1');
128
+ if (!NAME.test(manifest.name || '') || manifest.name !== folderName) {
129
+ throw new Error('Plugin name must match its lowercase folder name');
130
+ }
131
+ if (!VERSION.test(manifest.version || '')) throw new Error('Plugin version must use SemVer');
132
+ if (typeof manifest.description !== 'string' || !manifest.description.trim()) {
133
+ throw new Error('Plugin description is required');
134
+ }
135
+ const manifestTools = manifest.tools ?? [];
136
+ if (!Array.isArray(manifestTools)) throw new Error('Plugin tools must be an array');
137
+ if (manifestTools.length > MAX_TOOLS_PER_PLUGIN) {
138
+ throw new Error(`Plugin exceeds ${MAX_TOOLS_PER_PLUGIN} tools`);
139
+ }
140
+
141
+ const pluginCapabilities = validateCapabilities(manifest.capabilities, manifest.name);
142
+ const names = new Set();
143
+ const tools = manifestTools.map((tool) => {
144
+ if (!TOOL_NAME.test(tool?.name || '')) throw new Error(`Invalid tool name ${JSON.stringify(tool?.name)}`);
145
+ if (names.has(tool.name)) throw new Error(`Duplicate tool name ${tool.name}`);
146
+ names.add(tool.name);
147
+ if (typeof tool.description !== 'string' || !tool.description.trim()) {
148
+ throw new Error(`Tool ${tool.name} needs a description`);
149
+ }
150
+ if (!tool.parameters || tool.parameters.type !== 'object') {
151
+ throw new Error(`Tool ${tool.name} parameters must be an object JSON schema`);
152
+ }
153
+ const entry = resolveInside(directory, tool.entry);
154
+ regularFile(entry, MAX_ENTRY_BYTES);
155
+ const capabilities = validateCapabilities(tool.capabilities ?? pluginCapabilities, `${manifest.name}/${tool.name}`);
156
+ return {
157
+ localName: tool.name,
158
+ name: `plugin__${manifest.name.replace(/-/g, '_')}__${tool.name}`,
159
+ description: tool.description.trim().slice(0, 1000),
160
+ parameters: tool.parameters,
161
+ entry,
162
+ exportName: typeof tool.export === 'string' && tool.export ? tool.export : 'execute',
163
+ capabilities,
164
+ timeoutMs: Number.isInteger(tool.timeoutMs)
165
+ ? Math.max(1000, Math.min(tool.timeoutMs, 120_000))
166
+ : DEFAULT_TIMEOUT_MS
167
+ };
168
+ });
169
+ const skills = parsePluginSkills(directory, manifest.name, manifest.skills);
170
+ if (tools.length === 0 && skills.length === 0) {
171
+ throw new Error('Plugin must contribute at least one tool or skill');
172
+ }
173
+
174
+ return {
175
+ directory,
176
+ manifestFile,
177
+ // Pin every byte the plugin subprocess may read, not only the manifest.
178
+ // Otherwise an entry/helper could be replaced after review without
179
+ // changing plugin.json and would inherit stale trust.
180
+ digest: digestPluginDirectory(directory),
181
+ manifest: { ...manifest, capabilities: pluginCapabilities },
182
+ tools,
183
+ skills
184
+ };
185
+ }
186
+
187
+ function pluginDirectories() {
188
+ ensureRoot();
189
+ return fs.readdirSync(PLUGIN_ROOT, { withFileTypes: true })
190
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
191
+ .map((entry) => path.join(PLUGIN_ROOT, entry.name))
192
+ .sort();
193
+ }
194
+
195
+ export function listPlugins() {
196
+ const state = readState();
197
+ const rows = [];
198
+ for (const directory of pluginDirectories()) {
199
+ try {
200
+ const plugin = readPlugin(directory);
201
+ const trust = safeObject(state.plugins[plugin.manifest.name]);
202
+ const modified = Boolean(trust.enabled && trust.digest !== plugin.digest);
203
+ rows.push({
204
+ name: plugin.manifest.name,
205
+ version: plugin.manifest.version,
206
+ description: plugin.manifest.description,
207
+ enabled: Boolean(trust.enabled && !modified),
208
+ modified,
209
+ digest: plugin.digest,
210
+ capabilities: plugin.manifest.capabilities,
211
+ tools: plugin.tools.map((tool) => tool.name),
212
+ skills: plugin.skills.map((skill) => `${skill.domain}/${skill.id}`),
213
+ path: plugin.directory
214
+ });
215
+ } catch (error) {
216
+ rows.push({ name: path.basename(directory), enabled: false, invalid: true, error: error.message, path: directory });
217
+ }
218
+ }
219
+ return rows;
220
+ }
221
+
222
+ export function enablePlugin(name) {
223
+ if (!NAME.test(name || '')) return { ok: false, error: 'Invalid plugin name' };
224
+ try {
225
+ const plugin = readPlugin(resolveInside(PLUGIN_ROOT, name));
226
+ const state = readState();
227
+ state.plugins[name] = { enabled: true, digest: plugin.digest, enabledAt: Date.now() };
228
+ writeState(state);
229
+ return {
230
+ ok: true,
231
+ name,
232
+ digest: plugin.digest,
233
+ capabilities: plugin.manifest.capabilities,
234
+ tools: plugin.tools.map((tool) => tool.name),
235
+ skills: plugin.skills.map((skill) => `${skill.domain}/${skill.id}`)
236
+ };
237
+ } catch (error) {
238
+ return { ok: false, error: error.message };
239
+ }
240
+ }
241
+
242
+ export function disablePlugin(name) {
243
+ const state = readState();
244
+ const current = safeObject(state.plugins[name]);
245
+ state.plugins[name] = { ...current, enabled: false, disabledAt: Date.now() };
246
+ writeState(state);
247
+ return { ok: true, name };
248
+ }
249
+
250
+ export function createPlugin({ name, description, toolName, toolDescription, parameters, code, capabilities = [] }) {
251
+ if (!NAME.test(name || '')) return { ok: false, error: 'Plugin name must use lowercase letters, digits, and hyphens' };
252
+ if (!TOOL_NAME.test(toolName || '')) return { ok: false, error: 'Tool name must start with a lowercase letter and use letters, digits, _ or -' };
253
+ if (typeof description !== 'string' || !description.trim()) return { ok: false, error: 'Plugin description is required' };
254
+ if (typeof toolDescription !== 'string' || !toolDescription.trim()) return { ok: false, error: 'Tool description is required' };
255
+ if (!parameters || parameters.type !== 'object') return { ok: false, error: 'Tool parameters must be an object JSON schema' };
256
+ if (typeof code !== 'string' || !code.trim()) return { ok: false, error: 'Plugin code is required' };
257
+
258
+ let safeCapabilities;
259
+ try {
260
+ safeCapabilities = validateCapabilities(capabilities, name);
261
+ } catch (error) {
262
+ return { ok: false, error: error.message };
263
+ }
264
+
265
+ ensureRoot();
266
+ const directory = path.join(PLUGIN_ROOT, name);
267
+ if (fs.existsSync(directory)) return { ok: false, error: `Plugin ${name} already exists` };
268
+
269
+ const entryName = `${toolName}.js`;
270
+ const manifest = {
271
+ schemaVersion: 1,
272
+ name,
273
+ version: '0.1.0',
274
+ description: description.trim(),
275
+ capabilities: safeCapabilities,
276
+ tools: [{
277
+ name: toolName,
278
+ description: toolDescription.trim(),
279
+ parameters,
280
+ entry: entryName,
281
+ export: 'execute'
282
+ }]
283
+ };
284
+ const trimmedCode = code.trim();
285
+ const source = trimmedCode.startsWith('export ')
286
+ ? trimmedCode
287
+ : trimmedCode.startsWith('async ') || trimmedCode.startsWith('(async ')
288
+ ? `export const execute = ${trimmedCode};\n`
289
+ : `export async function execute(args, context) {\n${trimmedCode}\n}\n`;
290
+
291
+ try {
292
+ fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
293
+ fs.writeFileSync(path.join(directory, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
294
+ fs.writeFileSync(path.join(directory, entryName), source, { mode: 0o600 });
295
+ return {
296
+ ok: true,
297
+ name,
298
+ path: directory,
299
+ enabled: false,
300
+ message: 'Plugin created disabled. Review its manifest and source, then enable it explicitly.'
301
+ };
302
+ } catch (error) {
303
+ return { ok: false, error: error.message };
304
+ }
305
+ }
306
+
307
+ function enabledTools() {
308
+ const state = readState();
309
+ const tools = [];
310
+ for (const directory of pluginDirectories()) {
311
+ try {
312
+ const plugin = readPlugin(directory);
313
+ const trust = safeObject(state.plugins[plugin.manifest.name]);
314
+ if (!trust.enabled || trust.digest !== plugin.digest) continue;
315
+ for (const tool of plugin.tools) tools.push({ ...tool, plugin: plugin.manifest.name, pluginDirectory: directory });
316
+ } catch (error) {
317
+ // Invalid plugins are visible through listPlugins(), never through the model.
318
+ }
319
+ }
320
+ return tools;
321
+ }
322
+
323
+ export function getPluginToolDefinitions() {
324
+ return enabledTools().map((tool) => ({
325
+ name: tool.name,
326
+ description: tool.description,
327
+ parameters: tool.parameters,
328
+ xCausyneDeferred: true
329
+ }));
330
+ }
331
+
332
+ /**
333
+ * Reviewed, enabled plugins may contribute prompt-only skills. Discovery has
334
+ * already parsed these as inert UTF-8 data; no plugin module is imported.
335
+ */
336
+ export function getPluginSkills() {
337
+ const state = readState();
338
+ const skills = [];
339
+ for (const directory of pluginDirectories()) {
340
+ try {
341
+ const plugin = readPlugin(directory);
342
+ const trust = safeObject(state.plugins[plugin.manifest.name]);
343
+ if (!trust.enabled || trust.digest !== plugin.digest) continue;
344
+ skills.push(...plugin.skills);
345
+ } catch (error) {
346
+ // Invalid or modified plugins contribute nothing to the prompt.
347
+ }
348
+ }
349
+ return skills;
350
+ }
351
+
352
+ function permissionArgs(tool, workspaceRoot) {
353
+ // Node resolves `.js` module type by walking to package.json. Grant that one
354
+ // metadata file explicitly; granting the repository root would let an
355
+ // extension read Causyne's entire source tree for no functional reason.
356
+ const readable = [path.dirname(RUNNER_FILE), PACKAGE_FILE, tool.pluginDirectory];
357
+ const writable = [];
358
+ if (tool.capabilities.includes('workspace:read') || tool.capabilities.includes('workspace:write')) {
359
+ readable.push(workspaceRoot);
360
+ }
361
+ if (tool.capabilities.includes('workspace:write')) writable.push(workspaceRoot);
362
+
363
+ // Repeat the flag. Comma-joining paths is parsed as one literal path by
364
+ // Node's permission model on current releases.
365
+ const args = ['--permission', ...readable.map((entry) => `--allow-fs-read=${entry}`)];
366
+ if (writable.length > 0) {
367
+ args.push(...writable.map((entry) => `--allow-fs-write=${entry}`));
368
+ }
369
+ if (tool.capabilities.includes('network')) args.push('--allow-net');
370
+ if (tool.capabilities.includes('subprocess')) args.push('--allow-child-process');
371
+ return args;
372
+ }
373
+
374
+ async function runPluginTool(tool, args, context) {
375
+ const workspaceRoot = path.resolve(context?.workspaceRoot || context?.workspacePaths?.[0] || process.cwd());
376
+ // Resolve filesystem aliases before handing paths to Node's permission
377
+ // model. On macOS, /var and /tmp are symlinks into /private; authorizing the
378
+ // alias while the module loader opens the canonical target causes a false
379
+ // denial even though both names identify the same reviewed plugin file.
380
+ const runtimeTool = {
381
+ ...tool,
382
+ entry: fs.realpathSync(tool.entry),
383
+ pluginDirectory: fs.realpathSync(tool.pluginDirectory)
384
+ };
385
+ const childArgs = [
386
+ ...permissionArgs(runtimeTool, workspaceRoot),
387
+ RUNNER_FILE,
388
+ runtimeTool.entry,
389
+ runtimeTool.exportName
390
+ ];
391
+ const payload = JSON.stringify({
392
+ args: safeObject(args),
393
+ context: {
394
+ workspaceRoot,
395
+ pluginRoot: runtimeTool.pluginDirectory,
396
+ capabilities: runtimeTool.capabilities
397
+ }
398
+ });
399
+
400
+ return new Promise((resolve) => {
401
+ const child = spawn(process.execPath, childArgs, {
402
+ stdio: ['pipe', 'pipe', 'pipe'],
403
+ env: {
404
+ PATH: process.env.PATH || '',
405
+ LANG: process.env.LANG || 'C.UTF-8',
406
+ TMPDIR: process.env.TMPDIR || ''
407
+ }
408
+ });
409
+ let stdout = '';
410
+ let stderr = '';
411
+ let settled = false;
412
+ const finish = (value) => {
413
+ if (settled) return;
414
+ settled = true;
415
+ clearTimeout(timer);
416
+ resolve(value);
417
+ };
418
+ const timer = setTimeout(() => {
419
+ child.kill('SIGTERM');
420
+ finish({ success: false, error: `Plugin timed out after ${runtimeTool.timeoutMs} ms` });
421
+ }, runtimeTool.timeoutMs);
422
+ if (timer.unref) timer.unref();
423
+
424
+ child.stdout.on('data', (chunk) => {
425
+ stdout += chunk;
426
+ if (Buffer.byteLength(stdout, 'utf8') > MAX_RESULT_BYTES) {
427
+ child.kill('SIGTERM');
428
+ finish({ success: false, error: 'Plugin result exceeded 1 MB' });
429
+ }
430
+ });
431
+ child.stderr.on('data', (chunk) => {
432
+ stderr = (stderr + chunk).slice(-16_000);
433
+ });
434
+ child.on('error', (error) => finish({ success: false, error: error.message }));
435
+ child.on('close', () => {
436
+ const line = stdout.split(/\r?\n/).find((value) => value.startsWith('CAUSYNE_PLUGIN_RESULT:'));
437
+ if (!line) {
438
+ finish({ success: false, error: stderr.trim() || 'Plugin returned no structured result' });
439
+ return;
440
+ }
441
+ try {
442
+ finish(JSON.parse(line.slice('CAUSYNE_PLUGIN_RESULT:'.length)));
443
+ } catch (error) {
444
+ finish({ success: false, error: `Plugin returned invalid JSON: ${error.message}` });
445
+ }
446
+ });
447
+ child.stdin.end(payload);
448
+ });
449
+ }
450
+
451
+ export function resolvePluginTool(name) {
452
+ const tool = enabledTools().find((candidate) => candidate.name === name);
453
+ if (!tool) return null;
454
+ return {
455
+ schema: { name: tool.name, description: tool.description, parameters: tool.parameters },
456
+ execute: (args, context = {}) => runPluginTool(tool, args, context)
457
+ };
458
+ }
459
+
460
+ export { PLUGIN_ROOT, STATE_FILE, CAPABILITIES };
@@ -0,0 +1,50 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
3
+ const MAX_INPUT_BYTES = 1024 * 1024;
4
+ const RESULT_PREFIX = 'CAUSYNE_PLUGIN_RESULT:';
5
+
6
+ function writeResult(payload) {
7
+ process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(payload)}\n`);
8
+ }
9
+
10
+ async function readInput() {
11
+ let input = '';
12
+ for await (const chunk of process.stdin) {
13
+ input += chunk;
14
+ if (Buffer.byteLength(input, 'utf8') > MAX_INPUT_BYTES) {
15
+ throw new Error('Plugin input exceeds 1 MB');
16
+ }
17
+ }
18
+ return JSON.parse(input || '{}');
19
+ }
20
+
21
+ async function main() {
22
+ const entry = process.argv[2];
23
+ const exportName = process.argv[3] || 'execute';
24
+ if (!entry) throw new Error('Plugin entry path is required');
25
+
26
+ const payload = await readInput();
27
+ const imported = await import(pathToFileURL(entry).href);
28
+ const execute = imported[exportName];
29
+ if (typeof execute !== 'function') {
30
+ throw new Error(`Plugin export ${JSON.stringify(exportName)} is not a function`);
31
+ }
32
+
33
+ const result = await execute(payload.args || {}, payload.context || {});
34
+ writeResult(result && typeof result === 'object'
35
+ ? result
36
+ : { success: true, output: result });
37
+ }
38
+
39
+ console.log = (...values) => process.stderr.write(`${values.map(String).join(' ')}\n`);
40
+ console.info = console.log;
41
+ console.warn = console.log;
42
+ console.error = console.log;
43
+
44
+ main().catch((error) => {
45
+ const details = [error?.message || String(error)];
46
+ if (error?.permission) details.push(`permission=${error.permission}`);
47
+ if (error?.resource) details.push(`resource=${error.resource}`);
48
+ writeResult({ success: false, error: details.join(' ') });
49
+ process.exitCode = 1;
50
+ });
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const MAX_SKILLS_PER_PLUGIN = 32;
4
+ const MAX_SKILL_BYTES = 32 * 1024;
5
+ const SKILL_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ function resolveInside(root, relativePath) {
7
+ const resolvedRoot = path.resolve(root);
8
+ const resolved = path.resolve(root, relativePath);
9
+ if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
10
+ throw new Error(`Skill path escapes plugin root: ${relativePath}`);
11
+ }
12
+ return resolved;
13
+ }
14
+ function cleanLine(value, maximum) {
15
+ return String(value ?? '')
16
+ // eslint-disable-next-line no-control-regex
17
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
18
+ .replace(/\s+/g, ' ')
19
+ .trim()
20
+ .slice(0, maximum);
21
+ }
22
+ function readSkillFile(file) {
23
+ const stat = fs.lstatSync(file);
24
+ if (!stat.isFile() || stat.isSymbolicLink())
25
+ throw new Error(`${file} must be a regular file`);
26
+ if (stat.size > MAX_SKILL_BYTES)
27
+ throw new Error(`${file} exceeds ${MAX_SKILL_BYTES / 1024} KB`);
28
+ return fs.readFileSync(file, 'utf8')
29
+ // Keep newlines and tabs, but remove control bytes that can corrupt a prompt.
30
+ // eslint-disable-next-line no-control-regex
31
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
32
+ }
33
+ /**
34
+ * Validate plugin-contributed skills without importing or evaluating plugin
35
+ * code. A skill becomes visible only after the caller verifies the plugin's
36
+ * whole-directory digest against the explicit enablement record.
37
+ */
38
+ export function parsePluginSkills(directory, pluginName, value) {
39
+ if (value === undefined)
40
+ return [];
41
+ if (!Array.isArray(value))
42
+ throw new Error('Plugin skills must be an array');
43
+ if (value.length > MAX_SKILLS_PER_PLUGIN) {
44
+ throw new Error(`Plugin exceeds ${MAX_SKILLS_PER_PLUGIN} skills`);
45
+ }
46
+ const domain = `plugin-${pluginName}`;
47
+ const ids = new Set();
48
+ return value.map((raw, index) => {
49
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
50
+ throw new Error(`Skill ${index + 1} must be an object`);
51
+ }
52
+ const input = raw;
53
+ const id = String(input.id ?? '');
54
+ if (!SKILL_ID.test(id))
55
+ throw new Error(`Invalid plugin skill id ${JSON.stringify(id)}`);
56
+ if (ids.has(id))
57
+ throw new Error(`Duplicate plugin skill id ${id}`);
58
+ ids.add(id);
59
+ const name = cleanLine(input.name ?? id, 120);
60
+ const description = cleanLine(input.description, 400);
61
+ if (!name)
62
+ throw new Error(`Plugin skill ${id} needs a name`);
63
+ if (!description)
64
+ throw new Error(`Plugin skill ${id} needs a description`);
65
+ if (typeof input.file !== 'string' || !input.file) {
66
+ throw new Error(`Plugin skill ${id} needs a file`);
67
+ }
68
+ const file = resolveInside(directory, input.file);
69
+ const content = readSkillFile(file);
70
+ if (!content.trim())
71
+ throw new Error(`Plugin skill ${id} is empty`);
72
+ return {
73
+ domain,
74
+ id,
75
+ name,
76
+ description,
77
+ content,
78
+ source: 'plugin',
79
+ plugin: pluginName,
80
+ path: file
81
+ };
82
+ });
83
+ }
84
+ export { MAX_SKILLS_PER_PLUGIN, MAX_SKILL_BYTES };
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const MAX_SKILLS_PER_PLUGIN = 32;
5
+ const MAX_SKILL_BYTES = 32 * 1024;
6
+ const SKILL_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
7
+
8
+ export interface PluginSkillManifestInput {
9
+ id?: unknown;
10
+ name?: unknown;
11
+ description?: unknown;
12
+ file?: unknown;
13
+ }
14
+
15
+ export interface PluginSkill {
16
+ domain: string;
17
+ id: string;
18
+ name: string;
19
+ description: string;
20
+ content: string;
21
+ source: 'plugin';
22
+ plugin: string;
23
+ path: string;
24
+ }
25
+
26
+ function resolveInside(root: string, relativePath: string): string {
27
+ const resolvedRoot = path.resolve(root);
28
+ const resolved = path.resolve(root, relativePath);
29
+ if (resolved === resolvedRoot || !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
30
+ throw new Error(`Skill path escapes plugin root: ${relativePath}`);
31
+ }
32
+ return resolved;
33
+ }
34
+
35
+ function cleanLine(value: unknown, maximum: number): string {
36
+ return String(value ?? '')
37
+ // eslint-disable-next-line no-control-regex
38
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
39
+ .replace(/\s+/g, ' ')
40
+ .trim()
41
+ .slice(0, maximum);
42
+ }
43
+
44
+ function readSkillFile(file: string): string {
45
+ const stat = fs.lstatSync(file);
46
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} must be a regular file`);
47
+ if (stat.size > MAX_SKILL_BYTES) throw new Error(`${file} exceeds ${MAX_SKILL_BYTES / 1024} KB`);
48
+ return fs.readFileSync(file, 'utf8')
49
+ // Keep newlines and tabs, but remove control bytes that can corrupt a prompt.
50
+ // eslint-disable-next-line no-control-regex
51
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
52
+ }
53
+
54
+ /**
55
+ * Validate plugin-contributed skills without importing or evaluating plugin
56
+ * code. A skill becomes visible only after the caller verifies the plugin's
57
+ * whole-directory digest against the explicit enablement record.
58
+ */
59
+ export function parsePluginSkills(
60
+ directory: string,
61
+ pluginName: string,
62
+ value: unknown
63
+ ): PluginSkill[] {
64
+ if (value === undefined) return [];
65
+ if (!Array.isArray(value)) throw new Error('Plugin skills must be an array');
66
+ if (value.length > MAX_SKILLS_PER_PLUGIN) {
67
+ throw new Error(`Plugin exceeds ${MAX_SKILLS_PER_PLUGIN} skills`);
68
+ }
69
+
70
+ const domain = `plugin-${pluginName}`;
71
+ const ids = new Set<string>();
72
+ return value.map((raw, index) => {
73
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
74
+ throw new Error(`Skill ${index + 1} must be an object`);
75
+ }
76
+ const input = raw as PluginSkillManifestInput;
77
+ const id = String(input.id ?? '');
78
+ if (!SKILL_ID.test(id)) throw new Error(`Invalid plugin skill id ${JSON.stringify(id)}`);
79
+ if (ids.has(id)) throw new Error(`Duplicate plugin skill id ${id}`);
80
+ ids.add(id);
81
+
82
+ const name = cleanLine(input.name ?? id, 120);
83
+ const description = cleanLine(input.description, 400);
84
+ if (!name) throw new Error(`Plugin skill ${id} needs a name`);
85
+ if (!description) throw new Error(`Plugin skill ${id} needs a description`);
86
+ if (typeof input.file !== 'string' || !input.file) {
87
+ throw new Error(`Plugin skill ${id} needs a file`);
88
+ }
89
+ const file = resolveInside(directory, input.file);
90
+ const content = readSkillFile(file);
91
+ if (!content.trim()) throw new Error(`Plugin skill ${id} is empty`);
92
+
93
+ return {
94
+ domain,
95
+ id,
96
+ name,
97
+ description,
98
+ content,
99
+ source: 'plugin' as const,
100
+ plugin: pluginName,
101
+ path: file
102
+ };
103
+ });
104
+ }
105
+
106
+ export { MAX_SKILLS_PER_PLUGIN, MAX_SKILL_BYTES };