@causyne/agent 1.10.12 → 1.10.13

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.
@@ -0,0 +1,425 @@
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
+
8
+ const PLUGIN_ROOT = path.join(causyneHome(), 'plugins');
9
+ const STATE_FILE = path.join(PLUGIN_ROOT, 'state.json');
10
+ const RUNNER_FILE = fileURLToPath(new URL('./pluginRunner.js', import.meta.url));
11
+ const PACKAGE_FILE = fileURLToPath(new URL('../../package.json', import.meta.url));
12
+ const MAX_MANIFEST_BYTES = 64 * 1024;
13
+ const MAX_ENTRY_BYTES = 512 * 1024;
14
+ const MAX_PLUGIN_BYTES = 2 * 1024 * 1024;
15
+ const MAX_PLUGIN_FILES = 128;
16
+ const MAX_TOOLS_PER_PLUGIN = 32;
17
+ const MAX_RESULT_BYTES = 1024 * 1024;
18
+ const DEFAULT_TIMEOUT_MS = 30_000;
19
+ const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
+ const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
21
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
22
+ const CAPABILITIES = new Set([
23
+ 'workspace:read',
24
+ 'workspace:write',
25
+ 'network',
26
+ 'subprocess'
27
+ ]);
28
+
29
+ function ensureRoot() {
30
+ if (!fs.existsSync(PLUGIN_ROOT)) fs.mkdirSync(PLUGIN_ROOT, { recursive: true, mode: 0o700 });
31
+ }
32
+
33
+ function safeObject(value) {
34
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
35
+ }
36
+
37
+ function readState() {
38
+ try {
39
+ if (!fs.existsSync(STATE_FILE)) return { version: 1, plugins: {} };
40
+ const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
41
+ return { version: 1, plugins: safeObject(parsed.plugins) };
42
+ } catch (error) {
43
+ return { version: 1, plugins: {} };
44
+ }
45
+ }
46
+
47
+ function writeState(state) {
48
+ ensureRoot();
49
+ const temporary = `${STATE_FILE}.tmp_${process.pid}`;
50
+ fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
51
+ fs.renameSync(temporary, STATE_FILE);
52
+ }
53
+
54
+ function resolveInside(root, relativePath) {
55
+ const resolvedRoot = path.resolve(root);
56
+ const resolved = path.resolve(root, String(relativePath || ''));
57
+ if (resolved === resolvedRoot || !resolved.startsWith(resolvedRoot + path.sep)) {
58
+ throw new Error(`Path escapes plugin root: ${relativePath}`);
59
+ }
60
+ return resolved;
61
+ }
62
+
63
+ function regularFile(file, maxBytes) {
64
+ const stat = fs.lstatSync(file);
65
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} must be a regular file`);
66
+ if (stat.size > maxBytes) throw new Error(`${file} exceeds ${Math.round(maxBytes / 1024)} KB`);
67
+ return stat;
68
+ }
69
+
70
+ function digestPluginDirectory(directory) {
71
+ const files = [];
72
+ const visit = (current) => {
73
+ const entries = fs.readdirSync(current, { withFileTypes: true })
74
+ .sort((left, right) => left.name.localeCompare(right.name));
75
+ for (const entry of entries) {
76
+ const absolute = path.join(current, entry.name);
77
+ const stat = fs.lstatSync(absolute);
78
+ if (stat.isSymbolicLink()) throw new Error(`Plugin content must not be a symlink: ${entry.name}`);
79
+ if (stat.isDirectory()) {
80
+ visit(absolute);
81
+ continue;
82
+ }
83
+ if (!stat.isFile()) throw new Error(`Plugin content must be a regular file: ${entry.name}`);
84
+ files.push({ absolute, relative: path.relative(directory, absolute), size: stat.size });
85
+ if (files.length > MAX_PLUGIN_FILES) throw new Error(`Plugin exceeds ${MAX_PLUGIN_FILES} files`);
86
+ }
87
+ };
88
+ visit(directory);
89
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
90
+ if (totalBytes > MAX_PLUGIN_BYTES) throw new Error('Plugin exceeds 2 MB');
91
+
92
+ const digest = crypto.createHash('sha256');
93
+ for (const file of files) {
94
+ digest.update(file.relative);
95
+ digest.update('\0');
96
+ digest.update(fs.readFileSync(file.absolute));
97
+ digest.update('\0');
98
+ }
99
+ return digest.digest('hex');
100
+ }
101
+
102
+ function validateCapabilities(values, label) {
103
+ if (values == null) return [];
104
+ if (!Array.isArray(values)) throw new Error(`${label} capabilities must be an array`);
105
+ const unique = [];
106
+ for (const value of values) {
107
+ if (!CAPABILITIES.has(value)) throw new Error(`${label} requests unknown capability ${JSON.stringify(value)}`);
108
+ if (!unique.includes(value)) unique.push(value);
109
+ }
110
+ if (unique.includes('workspace:write') && !unique.includes('workspace:read')) unique.unshift('workspace:read');
111
+ return unique;
112
+ }
113
+
114
+ function readPlugin(directory) {
115
+ const directoryStat = fs.lstatSync(directory);
116
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
117
+ throw new Error('Plugin directory must not be a symlink');
118
+ }
119
+
120
+ const manifestFile = path.join(directory, 'plugin.json');
121
+ regularFile(manifestFile, MAX_MANIFEST_BYTES);
122
+ const raw = fs.readFileSync(manifestFile, 'utf8');
123
+ const manifest = JSON.parse(raw);
124
+ const folderName = path.basename(directory);
125
+
126
+ if (manifest.schemaVersion !== 1) throw new Error('schemaVersion must be 1');
127
+ if (!NAME.test(manifest.name || '') || manifest.name !== folderName) {
128
+ throw new Error('Plugin name must match its lowercase folder name');
129
+ }
130
+ if (!VERSION.test(manifest.version || '')) throw new Error('Plugin version must use SemVer');
131
+ if (typeof manifest.description !== 'string' || !manifest.description.trim()) {
132
+ throw new Error('Plugin description is required');
133
+ }
134
+ if (!Array.isArray(manifest.tools)) throw new Error('Plugin tools must be an array');
135
+ if (manifest.tools.length > MAX_TOOLS_PER_PLUGIN) {
136
+ throw new Error(`Plugin exceeds ${MAX_TOOLS_PER_PLUGIN} tools`);
137
+ }
138
+
139
+ const pluginCapabilities = validateCapabilities(manifest.capabilities, manifest.name);
140
+ const names = new Set();
141
+ const tools = manifest.tools.map((tool) => {
142
+ if (!TOOL_NAME.test(tool?.name || '')) throw new Error(`Invalid tool name ${JSON.stringify(tool?.name)}`);
143
+ if (names.has(tool.name)) throw new Error(`Duplicate tool name ${tool.name}`);
144
+ names.add(tool.name);
145
+ if (typeof tool.description !== 'string' || !tool.description.trim()) {
146
+ throw new Error(`Tool ${tool.name} needs a description`);
147
+ }
148
+ if (!tool.parameters || tool.parameters.type !== 'object') {
149
+ throw new Error(`Tool ${tool.name} parameters must be an object JSON schema`);
150
+ }
151
+ const entry = resolveInside(directory, tool.entry);
152
+ regularFile(entry, MAX_ENTRY_BYTES);
153
+ const capabilities = validateCapabilities(tool.capabilities ?? pluginCapabilities, `${manifest.name}/${tool.name}`);
154
+ return {
155
+ localName: tool.name,
156
+ name: `plugin__${manifest.name.replace(/-/g, '_')}__${tool.name}`,
157
+ description: tool.description.trim().slice(0, 1000),
158
+ parameters: tool.parameters,
159
+ entry,
160
+ exportName: typeof tool.export === 'string' && tool.export ? tool.export : 'execute',
161
+ capabilities,
162
+ timeoutMs: Number.isInteger(tool.timeoutMs)
163
+ ? Math.max(1000, Math.min(tool.timeoutMs, 120_000))
164
+ : DEFAULT_TIMEOUT_MS
165
+ };
166
+ });
167
+
168
+ return {
169
+ directory,
170
+ manifestFile,
171
+ // Pin every byte the plugin subprocess may read, not only the manifest.
172
+ // Otherwise an entry/helper could be replaced after review without
173
+ // changing plugin.json and would inherit stale trust.
174
+ digest: digestPluginDirectory(directory),
175
+ manifest: { ...manifest, capabilities: pluginCapabilities },
176
+ tools
177
+ };
178
+ }
179
+
180
+ function pluginDirectories() {
181
+ ensureRoot();
182
+ return fs.readdirSync(PLUGIN_ROOT, { withFileTypes: true })
183
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
184
+ .map((entry) => path.join(PLUGIN_ROOT, entry.name))
185
+ .sort();
186
+ }
187
+
188
+ export function listPlugins() {
189
+ const state = readState();
190
+ const rows = [];
191
+ for (const directory of pluginDirectories()) {
192
+ try {
193
+ const plugin = readPlugin(directory);
194
+ const trust = safeObject(state.plugins[plugin.manifest.name]);
195
+ const modified = Boolean(trust.enabled && trust.digest !== plugin.digest);
196
+ rows.push({
197
+ name: plugin.manifest.name,
198
+ version: plugin.manifest.version,
199
+ description: plugin.manifest.description,
200
+ enabled: Boolean(trust.enabled && !modified),
201
+ modified,
202
+ digest: plugin.digest,
203
+ capabilities: plugin.manifest.capabilities,
204
+ tools: plugin.tools.map((tool) => tool.name),
205
+ path: plugin.directory
206
+ });
207
+ } catch (error) {
208
+ rows.push({ name: path.basename(directory), enabled: false, invalid: true, error: error.message, path: directory });
209
+ }
210
+ }
211
+ return rows;
212
+ }
213
+
214
+ export function enablePlugin(name) {
215
+ if (!NAME.test(name || '')) return { ok: false, error: 'Invalid plugin name' };
216
+ try {
217
+ const plugin = readPlugin(resolveInside(PLUGIN_ROOT, name));
218
+ const state = readState();
219
+ state.plugins[name] = { enabled: true, digest: plugin.digest, enabledAt: Date.now() };
220
+ writeState(state);
221
+ return { ok: true, name, digest: plugin.digest, capabilities: plugin.manifest.capabilities, tools: plugin.tools.map((tool) => tool.name) };
222
+ } catch (error) {
223
+ return { ok: false, error: error.message };
224
+ }
225
+ }
226
+
227
+ export function disablePlugin(name) {
228
+ const state = readState();
229
+ const current = safeObject(state.plugins[name]);
230
+ state.plugins[name] = { ...current, enabled: false, disabledAt: Date.now() };
231
+ writeState(state);
232
+ return { ok: true, name };
233
+ }
234
+
235
+ export function createPlugin({ name, description, toolName, toolDescription, parameters, code, capabilities = [] }) {
236
+ if (!NAME.test(name || '')) return { ok: false, error: 'Plugin name must use lowercase letters, digits, and hyphens' };
237
+ if (!TOOL_NAME.test(toolName || '')) return { ok: false, error: 'Tool name must start with a lowercase letter and use letters, digits, _ or -' };
238
+ if (typeof description !== 'string' || !description.trim()) return { ok: false, error: 'Plugin description is required' };
239
+ if (typeof toolDescription !== 'string' || !toolDescription.trim()) return { ok: false, error: 'Tool description is required' };
240
+ if (!parameters || parameters.type !== 'object') return { ok: false, error: 'Tool parameters must be an object JSON schema' };
241
+ if (typeof code !== 'string' || !code.trim()) return { ok: false, error: 'Plugin code is required' };
242
+
243
+ let safeCapabilities;
244
+ try {
245
+ safeCapabilities = validateCapabilities(capabilities, name);
246
+ } catch (error) {
247
+ return { ok: false, error: error.message };
248
+ }
249
+
250
+ ensureRoot();
251
+ const directory = path.join(PLUGIN_ROOT, name);
252
+ if (fs.existsSync(directory)) return { ok: false, error: `Plugin ${name} already exists` };
253
+
254
+ const entryName = `${toolName}.js`;
255
+ const manifest = {
256
+ schemaVersion: 1,
257
+ name,
258
+ version: '0.1.0',
259
+ description: description.trim(),
260
+ capabilities: safeCapabilities,
261
+ tools: [{
262
+ name: toolName,
263
+ description: toolDescription.trim(),
264
+ parameters,
265
+ entry: entryName,
266
+ export: 'execute'
267
+ }]
268
+ };
269
+ const trimmedCode = code.trim();
270
+ const source = trimmedCode.startsWith('export ')
271
+ ? trimmedCode
272
+ : trimmedCode.startsWith('async ') || trimmedCode.startsWith('(async ')
273
+ ? `export const execute = ${trimmedCode};\n`
274
+ : `export async function execute(args, context) {\n${trimmedCode}\n}\n`;
275
+
276
+ try {
277
+ fs.mkdirSync(directory, { recursive: false, mode: 0o700 });
278
+ fs.writeFileSync(path.join(directory, 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
279
+ fs.writeFileSync(path.join(directory, entryName), source, { mode: 0o600 });
280
+ return {
281
+ ok: true,
282
+ name,
283
+ path: directory,
284
+ enabled: false,
285
+ message: 'Plugin created disabled. Review its manifest and source, then enable it explicitly.'
286
+ };
287
+ } catch (error) {
288
+ return { ok: false, error: error.message };
289
+ }
290
+ }
291
+
292
+ function enabledTools() {
293
+ const state = readState();
294
+ const tools = [];
295
+ for (const directory of pluginDirectories()) {
296
+ try {
297
+ const plugin = readPlugin(directory);
298
+ const trust = safeObject(state.plugins[plugin.manifest.name]);
299
+ if (!trust.enabled || trust.digest !== plugin.digest) continue;
300
+ for (const tool of plugin.tools) tools.push({ ...tool, plugin: plugin.manifest.name, pluginDirectory: directory });
301
+ } catch (error) {
302
+ // Invalid plugins are visible through listPlugins(), never through the model.
303
+ }
304
+ }
305
+ return tools;
306
+ }
307
+
308
+ export function getPluginToolDefinitions() {
309
+ return enabledTools().map((tool) => ({
310
+ name: tool.name,
311
+ description: tool.description,
312
+ parameters: tool.parameters,
313
+ xCausyneDeferred: true
314
+ }));
315
+ }
316
+
317
+ function permissionArgs(tool, workspaceRoot) {
318
+ // Node resolves `.js` module type by walking to package.json. Grant that one
319
+ // metadata file explicitly; granting the repository root would let an
320
+ // extension read Causyne's entire source tree for no functional reason.
321
+ const readable = [path.dirname(RUNNER_FILE), PACKAGE_FILE, tool.pluginDirectory];
322
+ const writable = [];
323
+ if (tool.capabilities.includes('workspace:read') || tool.capabilities.includes('workspace:write')) {
324
+ readable.push(workspaceRoot);
325
+ }
326
+ if (tool.capabilities.includes('workspace:write')) writable.push(workspaceRoot);
327
+
328
+ // Repeat the flag. Comma-joining paths is parsed as one literal path by
329
+ // Node's permission model on current releases.
330
+ const args = ['--permission', ...readable.map((entry) => `--allow-fs-read=${entry}`)];
331
+ if (writable.length > 0) {
332
+ args.push(...writable.map((entry) => `--allow-fs-write=${entry}`));
333
+ }
334
+ if (tool.capabilities.includes('network')) args.push('--allow-net');
335
+ if (tool.capabilities.includes('subprocess')) args.push('--allow-child-process');
336
+ return args;
337
+ }
338
+
339
+ async function runPluginTool(tool, args, context) {
340
+ const workspaceRoot = path.resolve(context?.workspaceRoot || context?.workspacePaths?.[0] || process.cwd());
341
+ // Resolve filesystem aliases before handing paths to Node's permission
342
+ // model. On macOS, /var and /tmp are symlinks into /private; authorizing the
343
+ // alias while the module loader opens the canonical target causes a false
344
+ // denial even though both names identify the same reviewed plugin file.
345
+ const runtimeTool = {
346
+ ...tool,
347
+ entry: fs.realpathSync(tool.entry),
348
+ pluginDirectory: fs.realpathSync(tool.pluginDirectory)
349
+ };
350
+ const childArgs = [
351
+ ...permissionArgs(runtimeTool, workspaceRoot),
352
+ RUNNER_FILE,
353
+ runtimeTool.entry,
354
+ runtimeTool.exportName
355
+ ];
356
+ const payload = JSON.stringify({
357
+ args: safeObject(args),
358
+ context: {
359
+ workspaceRoot,
360
+ pluginRoot: runtimeTool.pluginDirectory,
361
+ capabilities: runtimeTool.capabilities
362
+ }
363
+ });
364
+
365
+ return new Promise((resolve) => {
366
+ const child = spawn(process.execPath, childArgs, {
367
+ stdio: ['pipe', 'pipe', 'pipe'],
368
+ env: {
369
+ PATH: process.env.PATH || '',
370
+ LANG: process.env.LANG || 'C.UTF-8',
371
+ TMPDIR: process.env.TMPDIR || ''
372
+ }
373
+ });
374
+ let stdout = '';
375
+ let stderr = '';
376
+ let settled = false;
377
+ const finish = (value) => {
378
+ if (settled) return;
379
+ settled = true;
380
+ clearTimeout(timer);
381
+ resolve(value);
382
+ };
383
+ const timer = setTimeout(() => {
384
+ child.kill('SIGTERM');
385
+ finish({ success: false, error: `Plugin timed out after ${runtimeTool.timeoutMs} ms` });
386
+ }, runtimeTool.timeoutMs);
387
+ if (timer.unref) timer.unref();
388
+
389
+ child.stdout.on('data', (chunk) => {
390
+ stdout += chunk;
391
+ if (Buffer.byteLength(stdout, 'utf8') > MAX_RESULT_BYTES) {
392
+ child.kill('SIGTERM');
393
+ finish({ success: false, error: 'Plugin result exceeded 1 MB' });
394
+ }
395
+ });
396
+ child.stderr.on('data', (chunk) => {
397
+ stderr = (stderr + chunk).slice(-16_000);
398
+ });
399
+ child.on('error', (error) => finish({ success: false, error: error.message }));
400
+ child.on('close', () => {
401
+ const line = stdout.split(/\r?\n/).find((value) => value.startsWith('CAUSYNE_PLUGIN_RESULT:'));
402
+ if (!line) {
403
+ finish({ success: false, error: stderr.trim() || 'Plugin returned no structured result' });
404
+ return;
405
+ }
406
+ try {
407
+ finish(JSON.parse(line.slice('CAUSYNE_PLUGIN_RESULT:'.length)));
408
+ } catch (error) {
409
+ finish({ success: false, error: `Plugin returned invalid JSON: ${error.message}` });
410
+ }
411
+ });
412
+ child.stdin.end(payload);
413
+ });
414
+ }
415
+
416
+ export function resolvePluginTool(name) {
417
+ const tool = enabledTools().find((candidate) => candidate.name === name);
418
+ if (!tool) return null;
419
+ return {
420
+ schema: { name: tool.name, description: tool.description, parameters: tool.parameters },
421
+ execute: (args, context = {}) => runPluginTool(tool, args, context)
422
+ };
423
+ }
424
+
425
+ 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
+ });
package/src/sandbox.js CHANGED
@@ -183,6 +183,10 @@ export class Sandbox {
183
183
  const devMode = isDevEmulator(this.config);
184
184
  this.grantAllAccess = devMode ? true : (this.config.grantAllAccess || false);
185
185
  this.allowedTools = devMode ? ['*'] : (this.config.allowedTools || ['*']);
186
+ this.deniedTools = new Set(
187
+ (devMode ? [] : (this.config.deniedTools || []))
188
+ .map((name) => String(name).replace(/\./g, '_'))
189
+ );
186
190
  }
187
191
 
188
192
 
@@ -214,6 +218,9 @@ export class Sandbox {
214
218
 
215
219
  validate(toolName, args) {
216
220
  // 1. Tool availability check
221
+ if (this.deniedTools.has(String(toolName).replace(/\./g, '_'))) {
222
+ return { allowed: false, reason: `Tool ${toolName} is denied by local policy`, risk: 'forbidden' };
223
+ }
217
224
  if (!this.allowedTools.includes('*') && !this.allowedTools.includes(toolName)) {
218
225
  return { allowed: false, reason: `Tool ${toolName} not allowed on current tier`, risk: 'forbidden' };
219
226
  }
@@ -288,6 +295,20 @@ export class Sandbox {
288
295
  }
289
296
  return { allowed: true, risk: 'moderate' };
290
297
  }
298
+
299
+ // Plugins are code extensions. Creation and enablement change the future
300
+ // executable surface, and an enabled plugin call crosses from Causyne's
301
+ // reviewed code into user/third-party code. Keep both explicit and
302
+ // session-revocable even though the plugin process also runs under Node's
303
+ // filesystem/network/child-process permission model.
304
+ if (toolName === 'plugin_manager') {
305
+ return ['create', 'enable'].includes(args.action)
306
+ ? { allowed: true, risk: 'extreme', reason: 'Creates or trusts executable plugin code' }
307
+ : { allowed: true, risk: 'safe' };
308
+ }
309
+ if (toolName.startsWith('plugin__')) {
310
+ return { allowed: true, risk: 'extreme', reason: 'Executes an enabled local plugin' };
311
+ }
291
312
 
292
313
  // Tools that overwrite or delete are inherently dangerous.
293
314
  // These comparisons used the dotted schema names (`fs.delete`, `proc.term`,
package/src/taskShape.js CHANGED
@@ -18,7 +18,7 @@ const PACK_SIGNALS = {
18
18
  weak: ['process', 'proses', 'compress', 'extract', 'ekstrak', 'open app', 'launch']
19
19
  },
20
20
  advanced: {
21
- strong: ['pdf', 'forge a tool', 'custom tool', 'subagent', 'sub-agent', 'parallel agent'],
21
+ strong: ['pdf', 'forge a tool', 'custom tool', 'plugin', 'plugins', 'custom plugin', 'subagent', 'sub-agent', 'parallel agent'],
22
22
  weak: ['symbols', 'ast', 'refactor', 'orchestrate', 'swarm']
23
23
  }
24
24
  };
package/src/toolPacks.js CHANGED
@@ -106,6 +106,7 @@ export const PACKS = {
106
106
  ],
107
107
  advanced: [
108
108
  'tool_forge',
109
+ 'plugin_manager',
109
110
  'agent_subagent_orchestrate',
110
111
  'code_search_ast',
111
112
  'code_list_symbols',
@@ -364,7 +365,7 @@ const MEDIA_INTENT = /\b(image|images|picture|photo|gambar|render|3d model|blend
364
365
 
365
366
  const SYSTEM_INTENT = /\b(process|processes|pid|kill|port|zip|unzip|tar|archive|compress|extract|clipboard|copy to clipboard|print|printer|cetak|open in browser|launch app)\b/i;
366
367
 
367
- const ADVANCED_INTENT = /\b(pdf|forge a tool|custom tool|new tool|subagent|sub-agent|parallel agent|swarm|ast|symbols|refactor across)\b/i;
368
+ const ADVANCED_INTENT = /\b(pdf|forge a tool|custom tool|new tool|plugin|plugins|custom plugin|subagent|sub-agent|parallel agent|swarm|ast|symbols|refactor across)\b/i;
368
369
 
369
370
  const INTENT_MATCHERS = {
370
371
  game: GAME_INTENT,
@@ -457,9 +458,11 @@ export function resolveActiveTools(allDefinitions, unlockedPacks, unlockedTools,
457
458
  return isResidentTool(def.name) || tools.has(def.name);
458
459
  }
459
460
  if (tools.has(def.name)) return true;
461
+ if (def.xCausyneDeferred === true) return false;
460
462
  const memberOf = packsForTool(def.name);
461
- // A tool with no declared pack is a forged/custom tool: always available,
462
- // because the user explicitly created it for this machine.
463
+ // A native tool with no declared pack stays available. Manifest plugins are
464
+ // marked xCausyneDeferred and enter through tool_search, so enabling many
465
+ // plugins does not permanently spend their schemas on unrelated tasks.
463
466
  if (!memberOf) return true;
464
467
  for (const pack of memberOf) {
465
468
  if (packs.has(pack)) return true;
@@ -4,8 +4,8 @@
4
4
  // registry.js was always imported first. Loaded on demand instead — execute()
5
5
  // is async, so this costs nothing.
6
6
  async function toolDefinitions() {
7
- const { TOOL_DEFINITIONS } = await import('./registry.js');
8
- return TOOL_DEFINITIONS;
7
+ const { getSearchableToolDefinitions } = await import('./registry.js');
8
+ return getSearchableToolDefinitions();
9
9
  }
10
10
  import { PACKS, PACK_NAMES, packForTool, isCoreTool, isResidentTool } from '../toolPacks.js';
11
11
 
@@ -0,0 +1,61 @@
1
+ import {
2
+ createPlugin,
3
+ disablePlugin,
4
+ enablePlugin,
5
+ listPlugins
6
+ } from '../plugins/pluginManager.js';
7
+
8
+ export const pluginManagerTool = {
9
+ schema: {
10
+ name: 'plugin.manager',
11
+ description: 'Create and manage local Causyne plugins. Plugins package custom tools and run only after explicit enablement. Use list to inspect plugins; create writes a disabled plugin; enable trusts the current manifest digest; disable revokes it.',
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ action: { type: 'string', enum: ['list', 'create', 'enable', 'disable'] },
16
+ name: { type: 'string', description: 'Lowercase plugin name using letters, digits, and hyphens.' },
17
+ description: { type: 'string' },
18
+ toolName: { type: 'string' },
19
+ toolDescription: { type: 'string' },
20
+ parametersSchema: { type: 'string', description: 'JSON object schema for the custom tool arguments.' },
21
+ javascriptCode: { type: 'string', description: 'Body of async execute(args, context), or a complete module exporting execute.' },
22
+ capabilities: {
23
+ type: 'array',
24
+ items: { type: 'string', enum: ['workspace:read', 'workspace:write', 'network', 'subprocess'] }
25
+ }
26
+ },
27
+ required: ['action']
28
+ }
29
+ },
30
+
31
+ async execute(args) {
32
+ if (args.action === 'list') return { success: true, plugins: listPlugins() };
33
+ if (args.action === 'enable') {
34
+ const result = enablePlugin(args.name);
35
+ return result.ok ? { success: true, ...result } : { success: false, error: result.error };
36
+ }
37
+ if (args.action === 'disable') {
38
+ const result = disablePlugin(args.name);
39
+ return result.ok ? { success: true, ...result } : { success: false, error: result.error };
40
+ }
41
+ if (args.action === 'create') {
42
+ let parameters;
43
+ try {
44
+ parameters = JSON.parse(args.parametersSchema || '{}');
45
+ } catch (error) {
46
+ return { success: false, error: `parametersSchema is invalid JSON: ${error.message}` };
47
+ }
48
+ const result = createPlugin({
49
+ name: args.name,
50
+ description: args.description,
51
+ toolName: args.toolName,
52
+ toolDescription: args.toolDescription,
53
+ parameters,
54
+ code: args.javascriptCode,
55
+ capabilities: args.capabilities || []
56
+ });
57
+ return result.ok ? { success: true, ...result } : { success: false, error: result.error };
58
+ }
59
+ return { success: false, error: `Unknown plugin action: ${args.action}` };
60
+ }
61
+ };