@causyne/agent 1.10.13 → 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 (59) hide show
  1. package/docs/ARCHITECTURE.md +101 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +25 -3
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/camofoxRuntime.js +93 -0
  12. package/src/browser/camofoxRuntime.ts +112 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/commands.js +37 -8
  23. package/src/config.d.ts +2 -0
  24. package/src/hooks.js +121 -107
  25. package/src/hooks.ts +193 -0
  26. package/src/mcp/mcpClient.js +520 -132
  27. package/src/mcp/mcpClient.ts +584 -0
  28. package/src/mcp/mcpRegistry.js +399 -185
  29. package/src/mcp/mcpRegistry.ts +451 -0
  30. package/src/planCache.js +473 -450
  31. package/src/planCache.ts +840 -0
  32. package/src/plugins/pluginManager.js +40 -5
  33. package/src/plugins/pluginSkills.js +84 -0
  34. package/src/plugins/pluginSkills.ts +106 -0
  35. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  36. package/src/provisioning/capabilityProvisioner.js +5 -0
  37. package/src/provisioning/capabilityTools.js +2 -1
  38. package/src/sandbox.js +32 -2
  39. package/src/sessionJournal.js +471 -0
  40. package/src/sessionJournal.ts +496 -0
  41. package/src/sessionStore.js +3 -279
  42. package/src/skillsManager.js +18 -0
  43. package/src/subagentJournal.js +215 -0
  44. package/src/subagentJournal.ts +254 -0
  45. package/src/taskShape.js +179 -198
  46. package/src/taskShape.ts +259 -0
  47. package/src/tools/browserAutomator.js +45 -241
  48. package/src/tools/browserAutomator.ts +57 -0
  49. package/src/tools/manageLocalAiDependencies.js +1 -1
  50. package/src/tools/subagentOrchestrator.js +73 -12
  51. package/src/tools/webFetch.js +183 -68
  52. package/src/tools/webFetch.ts +185 -0
  53. package/src/tools/webSearch.js +50 -75
  54. package/src/tools/webSearch.ts +56 -0
  55. package/src/types/contracts.js +8 -0
  56. package/src/types/contracts.ts +263 -0
  57. package/src/utils/paths.d.ts +3 -0
  58. package/src/version.d.ts +1 -0
  59. package/src/mcp/jsonRpc.js +0 -165
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { causyneHome } from '../utils/paths.js';
7
+ import { parsePluginSkills } from './pluginSkills.js';
7
8
 
8
9
  const PLUGIN_ROOT = path.join(causyneHome(), 'plugins');
9
10
  const STATE_FILE = path.join(PLUGIN_ROOT, 'state.json');
@@ -131,14 +132,15 @@ function readPlugin(directory) {
131
132
  if (typeof manifest.description !== 'string' || !manifest.description.trim()) {
132
133
  throw new Error('Plugin description is required');
133
134
  }
134
- if (!Array.isArray(manifest.tools)) throw new Error('Plugin tools must be an array');
135
- if (manifest.tools.length > MAX_TOOLS_PER_PLUGIN) {
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) {
136
138
  throw new Error(`Plugin exceeds ${MAX_TOOLS_PER_PLUGIN} tools`);
137
139
  }
138
140
 
139
141
  const pluginCapabilities = validateCapabilities(manifest.capabilities, manifest.name);
140
142
  const names = new Set();
141
- const tools = manifest.tools.map((tool) => {
143
+ const tools = manifestTools.map((tool) => {
142
144
  if (!TOOL_NAME.test(tool?.name || '')) throw new Error(`Invalid tool name ${JSON.stringify(tool?.name)}`);
143
145
  if (names.has(tool.name)) throw new Error(`Duplicate tool name ${tool.name}`);
144
146
  names.add(tool.name);
@@ -164,6 +166,10 @@ function readPlugin(directory) {
164
166
  : DEFAULT_TIMEOUT_MS
165
167
  };
166
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
+ }
167
173
 
168
174
  return {
169
175
  directory,
@@ -173,7 +179,8 @@ function readPlugin(directory) {
173
179
  // changing plugin.json and would inherit stale trust.
174
180
  digest: digestPluginDirectory(directory),
175
181
  manifest: { ...manifest, capabilities: pluginCapabilities },
176
- tools
182
+ tools,
183
+ skills
177
184
  };
178
185
  }
179
186
 
@@ -202,6 +209,7 @@ export function listPlugins() {
202
209
  digest: plugin.digest,
203
210
  capabilities: plugin.manifest.capabilities,
204
211
  tools: plugin.tools.map((tool) => tool.name),
212
+ skills: plugin.skills.map((skill) => `${skill.domain}/${skill.id}`),
205
213
  path: plugin.directory
206
214
  });
207
215
  } catch (error) {
@@ -218,7 +226,14 @@ export function enablePlugin(name) {
218
226
  const state = readState();
219
227
  state.plugins[name] = { enabled: true, digest: plugin.digest, enabledAt: Date.now() };
220
228
  writeState(state);
221
- return { ok: true, name, digest: plugin.digest, capabilities: plugin.manifest.capabilities, tools: plugin.tools.map((tool) => tool.name) };
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
+ };
222
237
  } catch (error) {
223
238
  return { ok: false, error: error.message };
224
239
  }
@@ -314,6 +329,26 @@ export function getPluginToolDefinitions() {
314
329
  }));
315
330
  }
316
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
+
317
352
  function permissionArgs(tool, workspaceRoot) {
318
353
  // Node resolves `.js` module type by walking to package.json. Grant that one
319
354
  // metadata file explicitly; granting the repository root would let an
@@ -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 };
@@ -0,0 +1,10 @@
1
+ export interface CapabilityVerdict {
2
+ ok: boolean;
3
+ group: string;
4
+ reason?: string;
5
+ requiresConsent?: boolean;
6
+ }
7
+
8
+ export function ensureCapability(group: string): Promise<CapabilityVerdict>;
9
+ export function loadCapabilityPackage(packageName: string): Promise<unknown>;
10
+ export function capabilityDirectory(): string;
@@ -15,6 +15,10 @@ const CAPABILITY_DIR = path.join(causyneHome(), 'capabilities');
15
15
  // ("let it see the screen"), not once per npm package. Names stay in value
16
16
  // position — see the D-5 note above.
17
17
  const GROUP_PACKAGES = {
18
+ browser: [
19
+ ['camoufox-js', '0.11.5'],
20
+ ['playwright-core', '1.58.0']
21
+ ],
18
22
  vision: [
19
23
  ['puppeteer-core', '25.8.0'],
20
24
  ['screenshot-desktop', '1.15.6']
@@ -40,6 +44,7 @@ const GROUP_PACKAGES = {
40
44
 
41
45
  // than listing package names.
42
46
  export const GROUP_DESCRIPTIONS = {
47
+ browser: 'use an existing Camoufox installation for rendered navigation, extraction, and workspace-scoped downloads; Causyne never downloads the browser binary automatically',
43
48
  vision: 'drive a browser and capture the screen, so the agent can look at what it built',
44
49
  ocr: 'read text out of images and screenshots',
45
50
  system: 'list running processes and read system load',
@@ -11,7 +11,8 @@
11
11
  import { isGroupDeclined } from './capabilityConsent.js';
12
12
 
13
13
  export const TOOLS_BY_GROUP = {
14
- vision: ['browser_act', 'screenshot', 'vision_inspect_ui', 'game_canvas_audit', 'game_web_runtime_inspector'],
14
+ browser: ['browser_act'],
15
+ vision: ['screenshot', 'vision_inspect_ui', 'game_canvas_audit', 'game_web_runtime_inspector'],
15
16
  ocr: [],
16
17
  system: ['process_list'],
17
18
  desktop: ['clipboard', 'desktop_open'],
package/src/sandbox.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import os from 'os';
3
+ import { isIP } from 'node:net';
3
4
  import { loadConfig } from './config.js';
4
5
  import { isDevEmulator } from './devMode.js';
5
6
 
@@ -276,6 +277,27 @@ export class Sandbox {
276
277
  }
277
278
  }
278
279
 
280
+ if (toolName === 'browser_act') {
281
+ const actions = Array.isArray(args.actions) ? args.actions : [];
282
+ let hasDownload = false;
283
+ for (const action of actions) {
284
+ if (action && action.url && this.isInternalUrl(action.url)) {
285
+ return { allowed: false, reason: 'Cannot navigate the agent browser to an internal or invalid URL', risk: 'forbidden' };
286
+ }
287
+ if (action && ['download', 'screenshot'].includes(action.type)) {
288
+ if (!action.outputFile || !this.isPathAllowed(path.resolve(this.workspacePaths[0], action.outputFile))) {
289
+ return { allowed: false, reason: 'Browser downloads must stay inside the selected workspace', risk: 'forbidden' };
290
+ }
291
+ hasDownload = true;
292
+ }
293
+ }
294
+ if (hasDownload) return { allowed: true, risk: 'extreme', reason: 'Writes browser content to the selected workspace' };
295
+ if (actions.some(action => ['click', 'clickIndex', 'type', 'typeIndex', 'selectOption', 'keyPress'].includes(action?.type))) {
296
+ return { allowed: true, risk: 'dangerous', reason: 'Interacts with a website through the dedicated agent browser' };
297
+ }
298
+ return { allowed: true, risk: 'safe' };
299
+ }
300
+
279
301
  if (toolName === 'game_asset_fetch' && args.action === 'download_asset') {
280
302
  if (!args.url || this.isInternalUrl(args.url)) {
281
303
  return { allowed: false, reason: 'Cannot download from an internal or invalid URL', risk: 'forbidden' };
@@ -390,11 +412,19 @@ export class Sandbox {
390
412
  isInternalUrl(urlString) {
391
413
  try {
392
414
  const url = new URL(urlString);
393
- const host = url.hostname;
415
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return true;
416
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
417
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) return true;
394
418
 
395
419
  // Block localhost, private IPs, cloud metadata
396
- const blockedRegex = /^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0)$/;
420
+ const blockedRegex = /^(localhost|0\.\d+\.\d+\.\d+|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|192\.168\.\d+\.\d+|192\.0\.(0|2)\.\d+|192\.88\.99\.\d+|198\.(18|19)\.\d+\.\d+|198\.51\.100\.\d+|203\.0\.113\.\d+|(22[4-9]|23\d|24\d|25[0-5])\.\d+\.\d+\.\d+)$/;
397
421
  if (blockedRegex.test(host)) return true;
422
+ if (host === 'metadata.google.internal') return true;
423
+ if (isIP(host) === 6) {
424
+ const normalized = host.split('%')[0];
425
+ if (normalized === '::' || normalized === '::1' || /^(fc|fd|fe8|fe9|fea|feb|fec|fed|fee|fef|ff|2001:db8:|64:ff9b:)/.test(normalized)) return true;
426
+ if (normalized.startsWith('::ffff:')) return true;
427
+ }
398
428
 
399
429
  return false;
400
430
  } catch(e) {