@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.
- package/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +136 -7
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +11 -4
- package/src/agentLoop.js +161 -93
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/benchmarks/planCache.js +159 -0
- package/src/browser/camofoxRuntime.js +93 -0
- package/src/browser/camofoxRuntime.ts +112 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/commands.js +120 -8
- package/src/config.d.ts +2 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +150 -0
- package/src/hooks.ts +193 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +529 -225
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +460 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +53 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/toolPacks.js +6 -3
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/toolForge.js +47 -95
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
package/src/commands.js
CHANGED
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
} from './userSkills.js';
|
|
21
21
|
import { buildContextText, buildUsageText, buildCheckpointsText, buildUndoText, buildStatusText } from './reporting.js';
|
|
22
22
|
import { loadConfig, saveConfig } from './config.js';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
serverStatus, grantServer, revokeServer, addServer, removeServer,
|
|
25
|
+
connectServer, disconnectServer, refreshServer
|
|
26
|
+
} from './mcp/mcpRegistry.js';
|
|
24
27
|
import { analyzeFiles, formatFindings } from './slopDetector.js';
|
|
25
28
|
import { suggestMovements, formatMovementBrief, DESIGN_MOVEMENTS } from './designPalettes.js';
|
|
26
29
|
import fs from 'fs';
|
|
@@ -425,6 +428,89 @@ export const COMMANDS = [
|
|
|
425
428
|
}
|
|
426
429
|
},
|
|
427
430
|
|
|
431
|
+
{
|
|
432
|
+
name: 'plans',
|
|
433
|
+
scope: 'local',
|
|
434
|
+
usage: '/plans [import <json-file> | forget]',
|
|
435
|
+
description: 'Inspect, pre-warm, or clear reusable task plans',
|
|
436
|
+
dialogTitle: 'Plan cache',
|
|
437
|
+
run: async (args = []) => {
|
|
438
|
+
const cache = await import('./planCache.js');
|
|
439
|
+
const verb = String(args[0] || '').toLowerCase();
|
|
440
|
+
|
|
441
|
+
if (verb === 'forget') {
|
|
442
|
+
const removed = cache.forgetPlans();
|
|
443
|
+
return ok(`Forgot ${removed} cached plan${removed === 1 ? '' : 's'}.`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (verb === 'import') {
|
|
447
|
+
const requested = args.slice(1).join(' ').trim();
|
|
448
|
+
if (!requested) return fail('Usage: /plans import <json-file>');
|
|
449
|
+
const file = path.resolve(requested.replace(/^~(?=\/)/, os.homedir()));
|
|
450
|
+
try {
|
|
451
|
+
const stat = fs.statSync(file);
|
|
452
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) {
|
|
453
|
+
return fail('Plan seed must be one JSON file no larger than 1 MB.');
|
|
454
|
+
}
|
|
455
|
+
const outcome = cache.prewarmPlans(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
456
|
+
const detail = outcome.errors.length > 0 ? `\n${outcome.errors.join('\n')}` : '';
|
|
457
|
+
return outcome.accepted > 0
|
|
458
|
+
? ok(`Imported ${outcome.accepted} plan${outcome.accepted === 1 ? '' : 's'}; rejected ${outcome.rejected}.${detail}`)
|
|
459
|
+
: fail(`No plans were imported.${detail}`);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return fail(`Could not import plan seeds: ${error.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const stats = cache.planStats();
|
|
466
|
+
return ok([
|
|
467
|
+
`${stats.count} cached plan${stats.count === 1 ? '' : 's'}`,
|
|
468
|
+
`Hits: ${stats.hits}/${stats.lookups} (${(stats.hitRate * 100).toFixed(1)}%) — exact ${stats.exactHits}, fuzzy ${stats.fuzzyHits}`,
|
|
469
|
+
`Average lookup: ${stats.averageLookupMs.toFixed(3)} ms; max ${stats.maxLookupMs.toFixed(3)} ms`,
|
|
470
|
+
stats.disabled
|
|
471
|
+
? `Lookup cooling down until ${new Date(stats.disabledUntil).toISOString()} after persistent misses.`
|
|
472
|
+
: 'Lookup active.',
|
|
473
|
+
'',
|
|
474
|
+
'Pre-warm with /plans import <json-file>. Clear with /plans forget.'
|
|
475
|
+
].join('\n'));
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
{
|
|
480
|
+
name: 'plugins',
|
|
481
|
+
scope: 'local',
|
|
482
|
+
usage: '/plugins [enable <name> | disable <name>]',
|
|
483
|
+
description: 'Inspect or change trust for local plugins',
|
|
484
|
+
dialogTitle: 'Plugins',
|
|
485
|
+
run: async (args = []) => {
|
|
486
|
+
const manager = await import('./plugins/pluginManager.js');
|
|
487
|
+
const verb = String(args[0] || '').toLowerCase();
|
|
488
|
+
const name = String(args[1] || '');
|
|
489
|
+
if (verb === 'enable') {
|
|
490
|
+
if (!name) return fail('Usage: /plugins enable <name>');
|
|
491
|
+
const result = manager.enablePlugin(name);
|
|
492
|
+
return result.ok
|
|
493
|
+
? ok(`Enabled ${name} at manifest digest ${result.digest}. Changes will revoke trust automatically.`)
|
|
494
|
+
: fail(`Could not enable ${name}: ${result.error}`);
|
|
495
|
+
}
|
|
496
|
+
if (verb === 'disable') {
|
|
497
|
+
if (!name) return fail('Usage: /plugins disable <name>');
|
|
498
|
+
const result = manager.disablePlugin(name);
|
|
499
|
+
return result.ok ? ok(`Disabled ${name}.`) : fail(`Could not disable ${name}: ${result.error}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const rows = manager.listPlugins();
|
|
503
|
+
if (rows.length === 0) return ok(`No local plugins. Plugin root: ${manager.PLUGIN_ROOT}`);
|
|
504
|
+
return ok(rows.map((plugin) => {
|
|
505
|
+
const state = plugin.invalid ? 'invalid' : plugin.modified ? 'modified; trust revoked' : plugin.enabled ? 'enabled' : 'disabled';
|
|
506
|
+
const capabilities = Array.isArray(plugin.capabilities) && plugin.capabilities.length > 0
|
|
507
|
+
? plugin.capabilities.join(', ')
|
|
508
|
+
: 'none';
|
|
509
|
+
return ` ${plugin.name} ${plugin.version || ''} — ${state}; capabilities: ${capabilities}`;
|
|
510
|
+
}).join('\n') + `\n\nPlugin root: ${manager.PLUGIN_ROOT}`);
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
|
|
428
514
|
{
|
|
429
515
|
name: 'sessions',
|
|
430
516
|
aliases: ['session'],
|
|
@@ -574,10 +660,10 @@ export const COMMANDS = [
|
|
|
574
660
|
{
|
|
575
661
|
name: 'mcp',
|
|
576
662
|
scope: 'local',
|
|
577
|
-
usage: '/mcp [add <name> <cmd…> | add <name> --url <url> | remove|grant|revoke <name>]',
|
|
578
|
-
description: 'Manage
|
|
663
|
+
usage: '/mcp [add <name> <cmd…> | add <name> --url <url> | remove|grant|revoke|connect|disconnect|test|refresh <name>]',
|
|
664
|
+
description: 'Manage and diagnose MCP server connections',
|
|
579
665
|
dialogTitle: 'MCP servers',
|
|
580
|
-
run: (argv) => {
|
|
666
|
+
run: async (argv) => {
|
|
581
667
|
const [action, serverName, ...rest] = argv;
|
|
582
668
|
|
|
583
669
|
if (action === 'add') {
|
|
@@ -619,15 +705,40 @@ export const COMMANDS = [
|
|
|
619
705
|
const known = serverStatus().some(s => s.name === serverName);
|
|
620
706
|
if (!known) return fail(`No MCP server named "${serverName}" is configured.`);
|
|
621
707
|
if (action === 'grant') {
|
|
622
|
-
grantServer(serverName);
|
|
708
|
+
const granted = grantServer(serverName);
|
|
709
|
+
if (!granted.ok) return fail(granted.error);
|
|
623
710
|
return ok(`Granted "${serverName}". Its tools enter the catalogue on the next task.`);
|
|
624
711
|
}
|
|
625
712
|
revokeServer(serverName);
|
|
626
713
|
return ok(`Revoked "${serverName}". Its tools are no longer callable.`);
|
|
627
714
|
}
|
|
628
715
|
|
|
716
|
+
if (['connect', 'test', 'refresh', 'disconnect'].includes(action)) {
|
|
717
|
+
if (!serverName) return fail(`Usage: /mcp ${action} <server>`);
|
|
718
|
+
const known = serverStatus().some(server => server.name === serverName);
|
|
719
|
+
if (!known) return fail(`No MCP server named "${serverName}" is configured.`);
|
|
720
|
+
|
|
721
|
+
if (action === 'disconnect') {
|
|
722
|
+
await disconnectServer(serverName);
|
|
723
|
+
return ok(`Disconnected "${serverName}". Its grant remains in place.`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (action === 'refresh') {
|
|
727
|
+
const tools = await refreshServer(serverName);
|
|
728
|
+
return ok(`Refreshed "${serverName}": ${tools.length} tool(s) available${tools.length ? ` — ${tools.map(tool => tool.name).join(', ')}` : ''}.`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const client = await connectServer(serverName);
|
|
732
|
+
return ok(
|
|
733
|
+
`${action === 'test' ? 'MCP test passed' : 'Connected'}: "${serverName}"\n`
|
|
734
|
+
+ `Transport: ${client.status.transport || 'unknown'}\n`
|
|
735
|
+
+ `Server: ${client.serverInfo ? `${client.serverInfo.name} ${client.serverInfo.version}` : 'identity not provided'}\n`
|
|
736
|
+
+ `Tools: ${client.tools.length}${client.tools.length ? ` — ${client.tools.map(tool => tool.name).join(', ')}` : ''}`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
629
740
|
if (action) {
|
|
630
|
-
return fail(`Unknown mcp action "${action}". Try: add, remove, grant, revoke, or mcp on its own to list.`);
|
|
741
|
+
return fail(`Unknown mcp action "${action}". Try: add, remove, grant, revoke, connect, disconnect, test, refresh, or mcp on its own to list.`);
|
|
631
742
|
}
|
|
632
743
|
|
|
633
744
|
const servers = serverStatus();
|
|
@@ -636,12 +747,13 @@ export const COMMANDS = [
|
|
|
636
747
|
}
|
|
637
748
|
const lines = ['MCP servers', ''];
|
|
638
749
|
for (const server of servers) {
|
|
639
|
-
lines.push(` ${server.name} [${server.transport}] ${server.granted ? 'granted' : 'NOT granted'}${server.
|
|
750
|
+
lines.push(` ${server.name} [${server.transport}] ${server.granted ? 'granted' : 'NOT granted'} ${server.status}`);
|
|
640
751
|
lines.push(` ${server.target}`);
|
|
641
752
|
if (server.tools.length > 0) lines.push(` tools: ${server.tools.join(', ')}`);
|
|
753
|
+
if (server.error) lines.push(` error: ${server.error}`);
|
|
642
754
|
}
|
|
643
755
|
lines.push('');
|
|
644
|
-
lines.push(' mcp grant <name> to enable, mcp remove <name> to delete.');
|
|
756
|
+
lines.push(' mcp grant <name> to enable, mcp test <name> to diagnose, mcp remove <name> to delete.');
|
|
645
757
|
return ok(lines.join('\n'), { effect: 'pick-mcp' });
|
|
646
758
|
}
|
|
647
759
|
},
|
package/src/config.d.ts
ADDED
|
@@ -44,7 +44,7 @@ function serialiseContent(content) {
|
|
|
44
44
|
* Records what was attempted, which files were touched and what failed —
|
|
45
45
|
* exactly the things the agent asks itself again ten steps later.
|
|
46
46
|
*/
|
|
47
|
-
function buildStructuralSummary(droppedMessages) {
|
|
47
|
+
export function buildStructuralSummary(droppedMessages) {
|
|
48
48
|
const toolCounts = new Map();
|
|
49
49
|
const touchedPaths = new Set();
|
|
50
50
|
const failures = [];
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_HOOKS_PER_EVENT = 32;
|
|
4
|
+
const MAX_OUTPUT_BYTES = 32 * 1024;
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
6
|
+
function safeObject(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: {};
|
|
10
|
+
}
|
|
11
|
+
function matchesTool(rule, toolName) {
|
|
12
|
+
const tools = Array.isArray(rule.tools)
|
|
13
|
+
? rule.tools
|
|
14
|
+
: [rule.tool == null ? '*' : rule.tool];
|
|
15
|
+
return tools.some((candidate) => candidate === '*' || candidate === toolName);
|
|
16
|
+
}
|
|
17
|
+
function normalizeRule(rule, event, toolName) {
|
|
18
|
+
if (rule === null || typeof rule !== 'object' || Array.isArray(rule))
|
|
19
|
+
return null;
|
|
20
|
+
const candidate = rule;
|
|
21
|
+
if (candidate.enabled === false || candidate.event !== event || !matchesTool(candidate, toolName))
|
|
22
|
+
return null;
|
|
23
|
+
if (!Array.isArray(candidate.command) || candidate.command.length === 0)
|
|
24
|
+
return null;
|
|
25
|
+
const command = candidate.command.map((part) => String(part));
|
|
26
|
+
const executable = command[0];
|
|
27
|
+
if (!executable || command.length > 64 || command.some((part) => part.length > 4096))
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
name: String(candidate.name || `${event}:${toolName}`).slice(0, 128),
|
|
31
|
+
command: [executable, ...command.slice(1)],
|
|
32
|
+
timeoutMs: Number.isInteger(candidate.timeoutMs)
|
|
33
|
+
? Math.max(100, Math.min(candidate.timeoutMs, 10_000))
|
|
34
|
+
: DEFAULT_TIMEOUT_MS
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function hasMatchingHook(hooks, event, toolName) {
|
|
38
|
+
return (Array.isArray(hooks) ? hooks : [])
|
|
39
|
+
.some((rule) => normalizeRule(rule, event, toolName) !== null);
|
|
40
|
+
}
|
|
41
|
+
function runOneHook(rule, payload, cwd) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const child = spawn(rule.command[0], rule.command.slice(1), {
|
|
44
|
+
cwd,
|
|
45
|
+
shell: false,
|
|
46
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
47
|
+
env: {
|
|
48
|
+
PATH: process.env.PATH || '',
|
|
49
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
50
|
+
TMPDIR: process.env.TMPDIR || ''
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
let stdout = '';
|
|
54
|
+
let stderr = '';
|
|
55
|
+
let settled = false;
|
|
56
|
+
const finish = (value) => {
|
|
57
|
+
if (settled)
|
|
58
|
+
return;
|
|
59
|
+
settled = true;
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolve(value);
|
|
62
|
+
};
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
child.kill('SIGTERM');
|
|
65
|
+
finish({ exitCode: null, timedOut: true, stdout, stderr });
|
|
66
|
+
}, rule.timeoutMs);
|
|
67
|
+
timer.unref();
|
|
68
|
+
const collect = (target, chunk) => {
|
|
69
|
+
const next = target + chunk.toString();
|
|
70
|
+
if (Buffer.byteLength(next, 'utf8') > MAX_OUTPUT_BYTES) {
|
|
71
|
+
child.kill('SIGTERM');
|
|
72
|
+
finish({ exitCode: null, overflow: true, stdout, stderr });
|
|
73
|
+
return target;
|
|
74
|
+
}
|
|
75
|
+
return next;
|
|
76
|
+
};
|
|
77
|
+
child.stdout.on('data', (chunk) => { stdout = collect(stdout, chunk); });
|
|
78
|
+
child.stderr.on('data', (chunk) => { stderr = collect(stderr, chunk); });
|
|
79
|
+
child.on('error', (error) => finish({ exitCode: null, spawnError: error.message, stdout, stderr }));
|
|
80
|
+
child.on('close', (code) => finish({ exitCode: code, stdout, stderr }));
|
|
81
|
+
child.stdin.end(JSON.stringify(payload));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function feedbackFor(rule, result) {
|
|
85
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
86
|
+
if (output)
|
|
87
|
+
return `${rule.name}: ${output}`.slice(0, MAX_OUTPUT_BYTES);
|
|
88
|
+
if (result.timedOut)
|
|
89
|
+
return `${rule.name}: timed out after ${rule.timeoutMs} ms`;
|
|
90
|
+
if (result.overflow)
|
|
91
|
+
return `${rule.name}: output exceeded ${MAX_OUTPUT_BYTES} bytes`;
|
|
92
|
+
if (result.spawnError)
|
|
93
|
+
return `${rule.name}: ${result.spawnError}`;
|
|
94
|
+
if (result.exitCode !== 0)
|
|
95
|
+
return `${rule.name}: exited with code ${result.exitCode}`;
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
function resolveWorkspaceRoots(workspacePaths) {
|
|
99
|
+
if (!Array.isArray(workspacePaths))
|
|
100
|
+
return [process.cwd()];
|
|
101
|
+
const roots = workspacePaths
|
|
102
|
+
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
103
|
+
.map((value) => path.resolve(value));
|
|
104
|
+
return roots.length > 0 ? roots : [process.cwd()];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Run user-owned lifecycle hooks from ~/.causyne/config.json.
|
|
108
|
+
*
|
|
109
|
+
* Hooks are arrays of argv values and never pass through a shell. Project files
|
|
110
|
+
* are not searched for hooks, which prevents repository code from executing
|
|
111
|
+
* during startup before the user has trusted the workspace.
|
|
112
|
+
*/
|
|
113
|
+
export async function runConfiguredHooks({ hooks, event, toolName, args, result, taskId, toolCallId, workspacePaths }) {
|
|
114
|
+
const source = Array.isArray(hooks) ? hooks : [];
|
|
115
|
+
const rules = source
|
|
116
|
+
.map((rule) => normalizeRule(rule, event, toolName))
|
|
117
|
+
.filter((rule) => rule !== null)
|
|
118
|
+
.slice(0, MAX_HOOKS_PER_EVENT);
|
|
119
|
+
if (rules.length === 0)
|
|
120
|
+
return { denied: false, messages: [] };
|
|
121
|
+
const roots = resolveWorkspaceRoots(workspacePaths);
|
|
122
|
+
const primaryRoot = roots[0] ?? process.cwd();
|
|
123
|
+
const payload = {
|
|
124
|
+
event,
|
|
125
|
+
tool: toolName,
|
|
126
|
+
args: safeObject(args),
|
|
127
|
+
taskId: String(taskId || ''),
|
|
128
|
+
toolCallId: String(toolCallId || ''),
|
|
129
|
+
workspacePaths: roots
|
|
130
|
+
};
|
|
131
|
+
if (result !== null && typeof result === 'object')
|
|
132
|
+
payload.result = result;
|
|
133
|
+
const messages = [];
|
|
134
|
+
for (const rule of rules) {
|
|
135
|
+
const hookResult = await runOneHook(rule, payload, primaryRoot);
|
|
136
|
+
const feedback = feedbackFor(rule, hookResult);
|
|
137
|
+
if (feedback)
|
|
138
|
+
messages.push(feedback);
|
|
139
|
+
if (hookResult.exitCode === 2) {
|
|
140
|
+
return feedback
|
|
141
|
+
? { denied: true, messages, reason: feedback }
|
|
142
|
+
: { denied: true, messages };
|
|
143
|
+
}
|
|
144
|
+
if (event === 'PreToolUse' && hookResult.exitCode !== 0) {
|
|
145
|
+
return { denied: true, messages, reason: feedback || `${rule.name} failed closed` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { denied: false, messages };
|
|
149
|
+
}
|
|
150
|
+
export { MAX_HOOKS_PER_EVENT, MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS };
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { HookDecision, HookEvent } from './types/contracts.js';
|
|
5
|
+
|
|
6
|
+
const MAX_HOOKS_PER_EVENT = 32;
|
|
7
|
+
const MAX_OUTPUT_BYTES = 32 * 1024;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
type UnknownRecord = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
interface NormalizedHookRule {
|
|
13
|
+
name: string;
|
|
14
|
+
command: [string, ...string[]];
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface HookProcessResult {
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
timedOut?: boolean;
|
|
23
|
+
overflow?: boolean;
|
|
24
|
+
spawnError?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RunConfiguredHooksOptions {
|
|
28
|
+
hooks?: unknown;
|
|
29
|
+
event: HookEvent;
|
|
30
|
+
toolName: string;
|
|
31
|
+
args?: unknown;
|
|
32
|
+
result?: unknown;
|
|
33
|
+
taskId?: unknown;
|
|
34
|
+
toolCallId?: unknown;
|
|
35
|
+
workspacePaths?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeObject(value: unknown): UnknownRecord {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
40
|
+
? value as UnknownRecord
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matchesTool(rule: UnknownRecord, toolName: string): boolean {
|
|
45
|
+
const tools = Array.isArray(rule.tools)
|
|
46
|
+
? rule.tools
|
|
47
|
+
: [rule.tool == null ? '*' : rule.tool];
|
|
48
|
+
return tools.some((candidate) => candidate === '*' || candidate === toolName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeRule(rule: unknown, event: HookEvent, toolName: string): NormalizedHookRule | null {
|
|
52
|
+
if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) return null;
|
|
53
|
+
const candidate = rule as UnknownRecord;
|
|
54
|
+
if (candidate.enabled === false || candidate.event !== event || !matchesTool(candidate, toolName)) return null;
|
|
55
|
+
if (!Array.isArray(candidate.command) || candidate.command.length === 0) return null;
|
|
56
|
+
|
|
57
|
+
const command = candidate.command.map((part) => String(part));
|
|
58
|
+
const executable = command[0];
|
|
59
|
+
if (!executable || command.length > 64 || command.some((part) => part.length > 4096)) return null;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: String(candidate.name || `${event}:${toolName}`).slice(0, 128),
|
|
63
|
+
command: [executable, ...command.slice(1)],
|
|
64
|
+
timeoutMs: Number.isInteger(candidate.timeoutMs)
|
|
65
|
+
? Math.max(100, Math.min(candidate.timeoutMs as number, 10_000))
|
|
66
|
+
: DEFAULT_TIMEOUT_MS
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function hasMatchingHook(hooks: unknown, event: HookEvent, toolName: string): boolean {
|
|
71
|
+
return (Array.isArray(hooks) ? hooks : [])
|
|
72
|
+
.some((rule) => normalizeRule(rule, event, toolName) !== null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runOneHook(rule: NormalizedHookRule, payload: UnknownRecord, cwd: string): Promise<HookProcessResult> {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const child = spawn(rule.command[0], rule.command.slice(1), {
|
|
78
|
+
cwd,
|
|
79
|
+
shell: false,
|
|
80
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
|
+
env: {
|
|
82
|
+
PATH: process.env.PATH || '',
|
|
83
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
84
|
+
TMPDIR: process.env.TMPDIR || ''
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
let stdout = '';
|
|
88
|
+
let stderr = '';
|
|
89
|
+
let settled = false;
|
|
90
|
+
|
|
91
|
+
const finish = (value: HookProcessResult): void => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve(value);
|
|
96
|
+
};
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
child.kill('SIGTERM');
|
|
99
|
+
finish({ exitCode: null, timedOut: true, stdout, stderr });
|
|
100
|
+
}, rule.timeoutMs);
|
|
101
|
+
timer.unref();
|
|
102
|
+
|
|
103
|
+
const collect = (target: string, chunk: Buffer | string): string => {
|
|
104
|
+
const next = target + chunk.toString();
|
|
105
|
+
if (Buffer.byteLength(next, 'utf8') > MAX_OUTPUT_BYTES) {
|
|
106
|
+
child.kill('SIGTERM');
|
|
107
|
+
finish({ exitCode: null, overflow: true, stdout, stderr });
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
return next;
|
|
111
|
+
};
|
|
112
|
+
child.stdout.on('data', (chunk: Buffer | string) => { stdout = collect(stdout, chunk); });
|
|
113
|
+
child.stderr.on('data', (chunk: Buffer | string) => { stderr = collect(stderr, chunk); });
|
|
114
|
+
child.on('error', (error: Error) => finish({ exitCode: null, spawnError: error.message, stdout, stderr }));
|
|
115
|
+
child.on('close', (code: number | null) => finish({ exitCode: code, stdout, stderr }));
|
|
116
|
+
child.stdin.end(JSON.stringify(payload));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function feedbackFor(rule: NormalizedHookRule, result: HookProcessResult): string {
|
|
121
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
122
|
+
if (output) return `${rule.name}: ${output}`.slice(0, MAX_OUTPUT_BYTES);
|
|
123
|
+
if (result.timedOut) return `${rule.name}: timed out after ${rule.timeoutMs} ms`;
|
|
124
|
+
if (result.overflow) return `${rule.name}: output exceeded ${MAX_OUTPUT_BYTES} bytes`;
|
|
125
|
+
if (result.spawnError) return `${rule.name}: ${result.spawnError}`;
|
|
126
|
+
if (result.exitCode !== 0) return `${rule.name}: exited with code ${result.exitCode}`;
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveWorkspaceRoots(workspacePaths: unknown): string[] {
|
|
131
|
+
if (!Array.isArray(workspacePaths)) return [process.cwd()];
|
|
132
|
+
const roots = workspacePaths
|
|
133
|
+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
134
|
+
.map((value) => path.resolve(value));
|
|
135
|
+
return roots.length > 0 ? roots : [process.cwd()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run user-owned lifecycle hooks from ~/.causyne/config.json.
|
|
140
|
+
*
|
|
141
|
+
* Hooks are arrays of argv values and never pass through a shell. Project files
|
|
142
|
+
* are not searched for hooks, which prevents repository code from executing
|
|
143
|
+
* during startup before the user has trusted the workspace.
|
|
144
|
+
*/
|
|
145
|
+
export async function runConfiguredHooks({
|
|
146
|
+
hooks,
|
|
147
|
+
event,
|
|
148
|
+
toolName,
|
|
149
|
+
args,
|
|
150
|
+
result,
|
|
151
|
+
taskId,
|
|
152
|
+
toolCallId,
|
|
153
|
+
workspacePaths
|
|
154
|
+
}: RunConfiguredHooksOptions): Promise<HookDecision> {
|
|
155
|
+
const source: unknown[] = Array.isArray(hooks) ? hooks : [];
|
|
156
|
+
const rules = source
|
|
157
|
+
.map((rule) => normalizeRule(rule, event, toolName))
|
|
158
|
+
.filter((rule): rule is NormalizedHookRule => rule !== null)
|
|
159
|
+
.slice(0, MAX_HOOKS_PER_EVENT);
|
|
160
|
+
if (rules.length === 0) return { denied: false, messages: [] };
|
|
161
|
+
|
|
162
|
+
const roots = resolveWorkspaceRoots(workspacePaths);
|
|
163
|
+
const primaryRoot = roots[0] ?? process.cwd();
|
|
164
|
+
const payload: UnknownRecord = {
|
|
165
|
+
event,
|
|
166
|
+
tool: toolName,
|
|
167
|
+
args: safeObject(args),
|
|
168
|
+
taskId: String(taskId || ''),
|
|
169
|
+
toolCallId: String(toolCallId || ''),
|
|
170
|
+
workspacePaths: roots
|
|
171
|
+
};
|
|
172
|
+
if (result !== null && typeof result === 'object') payload.result = result;
|
|
173
|
+
const messages: string[] = [];
|
|
174
|
+
|
|
175
|
+
for (const rule of rules) {
|
|
176
|
+
const hookResult = await runOneHook(rule, payload, primaryRoot);
|
|
177
|
+
const feedback = feedbackFor(rule, hookResult);
|
|
178
|
+
if (feedback) messages.push(feedback);
|
|
179
|
+
|
|
180
|
+
if (hookResult.exitCode === 2) {
|
|
181
|
+
return feedback
|
|
182
|
+
? { denied: true, messages, reason: feedback }
|
|
183
|
+
: { denied: true, messages };
|
|
184
|
+
}
|
|
185
|
+
if (event === 'PreToolUse' && hookResult.exitCode !== 0) {
|
|
186
|
+
return { denied: true, messages, reason: feedback || `${rule.name} failed closed` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { denied: false, messages };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export { MAX_HOOKS_PER_EVENT, MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS };
|