@phnx-labs/agents-cli 1.18.5 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.md +22 -20
  3. package/dist/commands/browser.js +25 -2
  4. package/dist/commands/cloud.js +3 -3
  5. package/dist/commands/computer.d.ts +6 -0
  6. package/dist/commands/computer.js +477 -0
  7. package/dist/commands/doctor.js +19 -17
  8. package/dist/commands/exec.js +37 -59
  9. package/dist/commands/factory.js +12 -5
  10. package/dist/commands/import.js +6 -1
  11. package/dist/commands/mcp.js +9 -4
  12. package/dist/commands/packages.d.ts +3 -0
  13. package/dist/commands/packages.js +20 -12
  14. package/dist/commands/permissions.d.ts +2 -0
  15. package/dist/commands/permissions.js +20 -1
  16. package/dist/commands/plugins.d.ts +2 -0
  17. package/dist/commands/plugins.js +23 -4
  18. package/dist/commands/profiles.js +1 -1
  19. package/dist/commands/pty.js +126 -112
  20. package/dist/commands/pull.js +29 -25
  21. package/dist/commands/repo.js +24 -26
  22. package/dist/commands/routines.js +29 -26
  23. package/dist/commands/secrets.js +66 -73
  24. package/dist/commands/sessions-tail.js +21 -22
  25. package/dist/commands/sessions.js +36 -68
  26. package/dist/commands/setup.js +20 -24
  27. package/dist/commands/teams.js +30 -39
  28. package/dist/commands/versions.js +60 -68
  29. package/dist/commands/worktree.d.ts +20 -0
  30. package/dist/commands/worktree.js +242 -0
  31. package/dist/computer.d.ts +2 -0
  32. package/dist/computer.js +7 -0
  33. package/dist/index.js +70 -26
  34. package/dist/lib/agents.d.ts +4 -1
  35. package/dist/lib/agents.js +23 -5
  36. package/dist/lib/browser/cdp.d.ts +15 -1
  37. package/dist/lib/browser/cdp.js +77 -8
  38. package/dist/lib/browser/chrome.js +17 -24
  39. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  40. package/dist/lib/browser/drivers/ssh.js +20 -8
  41. package/dist/lib/browser/ipc.js +38 -5
  42. package/dist/lib/browser/profiles.js +34 -2
  43. package/dist/lib/browser/runtime-state.d.ts +1 -2
  44. package/dist/lib/browser/runtime-state.js +11 -3
  45. package/dist/lib/browser/service.d.ts +5 -0
  46. package/dist/lib/browser/service.js +32 -4
  47. package/dist/lib/browser/types.d.ts +1 -1
  48. package/dist/lib/browser/upload.d.ts +2 -0
  49. package/dist/lib/browser/upload.js +34 -0
  50. package/dist/lib/cloud/rush.d.ts +2 -1
  51. package/dist/lib/cloud/rush.js +28 -9
  52. package/dist/lib/computer-rpc.d.ts +24 -0
  53. package/dist/lib/computer-rpc.js +263 -0
  54. package/dist/lib/daemon.js +7 -7
  55. package/dist/lib/exec.d.ts +2 -1
  56. package/dist/lib/exec.js +3 -2
  57. package/dist/lib/fs-atomic.d.ts +18 -0
  58. package/dist/lib/fs-atomic.js +76 -0
  59. package/dist/lib/git.js +2 -4
  60. package/dist/lib/help.d.ts +15 -0
  61. package/dist/lib/help.js +41 -0
  62. package/dist/lib/hooks/match.d.ts +1 -0
  63. package/dist/lib/hooks/match.js +57 -12
  64. package/dist/lib/hooks.d.ts +1 -0
  65. package/dist/lib/hooks.js +27 -10
  66. package/dist/lib/import.d.ts +1 -0
  67. package/dist/lib/import.js +7 -0
  68. package/dist/lib/manifest.js +27 -1
  69. package/dist/lib/mcp.d.ts +14 -0
  70. package/dist/lib/mcp.js +79 -14
  71. package/dist/lib/migrate.js +3 -3
  72. package/dist/lib/models.js +3 -1
  73. package/dist/lib/permissions.d.ts +5 -0
  74. package/dist/lib/permissions.js +35 -0
  75. package/dist/lib/plugin-marketplace.d.ts +3 -1
  76. package/dist/lib/plugin-marketplace.js +36 -1
  77. package/dist/lib/plugins.d.ts +19 -1
  78. package/dist/lib/plugins.js +99 -8
  79. package/dist/lib/redact.d.ts +4 -0
  80. package/dist/lib/redact.js +18 -0
  81. package/dist/lib/registry.d.ts +2 -0
  82. package/dist/lib/registry.js +15 -0
  83. package/dist/lib/sandbox.js +15 -5
  84. package/dist/lib/secrets/bundles.d.ts +7 -12
  85. package/dist/lib/secrets/bundles.js +45 -29
  86. package/dist/lib/secrets/index.js +4 -4
  87. package/dist/lib/session/cloud.d.ts +2 -0
  88. package/dist/lib/session/cloud.js +34 -6
  89. package/dist/lib/session/parse.js +7 -2
  90. package/dist/lib/session/render.d.ts +4 -1
  91. package/dist/lib/session/render.js +81 -35
  92. package/dist/lib/shims.d.ts +5 -2
  93. package/dist/lib/shims.js +29 -7
  94. package/dist/lib/state.d.ts +5 -5
  95. package/dist/lib/state.js +43 -13
  96. package/dist/lib/teams/agents.d.ts +1 -1
  97. package/dist/lib/teams/agents.js +2 -2
  98. package/dist/lib/types.d.ts +4 -3
  99. package/dist/lib/types.js +0 -2
  100. package/dist/lib/versions.js +65 -40
  101. package/dist/lib/workflows.d.ts +7 -0
  102. package/dist/lib/workflows.js +42 -1
  103. package/npm-shrinkwrap.json +3256 -0
  104. package/package.json +32 -26
  105. package/scripts/postinstall.js +8 -2
@@ -19,6 +19,41 @@ const HOME = os.homedir();
19
19
  export const PERMISSIONS_CAPABLE_AGENTS = ['claude', 'codex', 'opencode'];
20
20
  /** Filename used for Codex Starlark deny-rules generated from permission groups. */
21
21
  export const CODEX_RULES_FILENAME = 'agents-deny.rules';
22
+ export function containsBroadGrants(rules) {
23
+ const broad = [];
24
+ const reasons = new Set();
25
+ for (const perm of rules.allow) {
26
+ if (BLANKET_BASH_FORMS.has(perm)) {
27
+ broad.push(perm);
28
+ reasons.add('allows any bash command and maps Codex to approval_policy=never');
29
+ continue;
30
+ }
31
+ const parsed = parseCanonicalPattern(perm);
32
+ if (!parsed)
33
+ continue;
34
+ if (parsed.tool === 'bash' && (parsed.pattern === '*' || parsed.pattern === '**')) {
35
+ broad.push(perm);
36
+ reasons.add('allows any bash command and maps Codex to approval_policy=never');
37
+ }
38
+ else if (parsed.tool === 'bash' && parsed.pattern.startsWith('/') && parsed.pattern.includes('*')) {
39
+ broad.push(perm);
40
+ reasons.add('allows wildcard absolute bash paths');
41
+ }
42
+ else if ((parsed.tool === 'write' || parsed.tool === 'read') && (parsed.pattern === '*' || parsed.pattern === '**')) {
43
+ broad.push(perm);
44
+ reasons.add(`allows broad ${parsed.tool} filesystem access`);
45
+ }
46
+ }
47
+ for (const dir of rules.additionalDirectories || []) {
48
+ if (dir.startsWith('/') || dir.startsWith('~/') || dir === '~' || dir.split(/[\\/]/).includes('..')) {
49
+ broad.push(`additionalDirectories:${dir}`);
50
+ reasons.add('adds a broad or parent-traversing sandbox directory');
51
+ }
52
+ }
53
+ if (broad.length === 0)
54
+ return null;
55
+ return { broad, reason: Array.from(reasons).join('; ') };
56
+ }
22
57
  /**
23
58
  * Convert canonical deny rules to Codex Starlark .rules format.
24
59
  * E.g. "Bash(git reset:*)" -> prefix_rule(pattern=["git", "reset"], decision="forbidden")
@@ -68,7 +68,9 @@ export declare function unregisterMarketplace(agent: AgentId, versionHome: strin
68
68
  * enabledPlugins["<plugin>@agents-cli"]: true. Reads, mutates, writes —
69
69
  * preserving every other key.
70
70
  */
71
- export declare function enablePluginInSettings(pluginName: string, agent: AgentId, versionHome: string): void;
71
+ export declare function enablePluginInSettings(pluginName: string, agent: AgentId, versionHome: string, options?: {
72
+ allowExecSurfaces?: boolean;
73
+ }): void;
72
74
  /**
73
75
  * Remove the enabledPlugins key for this plugin. Inverse of enablePluginInSettings.
74
76
  */
@@ -151,7 +151,10 @@ export function unregisterMarketplace(agent, versionHome) {
151
151
  * enabledPlugins["<plugin>@agents-cli"]: true. Reads, mutates, writes —
152
152
  * preserving every other key.
153
153
  */
154
- export function enablePluginInSettings(pluginName, agent, versionHome) {
154
+ export function enablePluginInSettings(pluginName, agent, versionHome, options = {}) {
155
+ if (!options.allowExecSurfaces && marketplacePluginHasExecSurfaces(pluginName, agent, versionHome)) {
156
+ return;
157
+ }
155
158
  const sPath = settingsPath(agent, versionHome);
156
159
  let settings = {};
157
160
  if (fs.existsSync(sPath)) {
@@ -173,6 +176,38 @@ export function enablePluginInSettings(pluginName, agent, versionHome) {
173
176
  fs.mkdirSync(path.dirname(sPath), { recursive: true });
174
177
  fs.writeFileSync(sPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
175
178
  }
179
+ function marketplacePluginHasExecSurfaces(pluginName, agent, versionHome) {
180
+ const root = path.join(marketplaceRoot(agent, versionHome), 'plugins', pluginName);
181
+ if (fs.existsSync(path.join(root, '.mcp.json')))
182
+ return true;
183
+ for (const dir of ['bin', 'scripts', 'permissions']) {
184
+ if (fs.existsSync(path.join(root, dir)))
185
+ return true;
186
+ }
187
+ const hooksFile = path.join(root, 'hooks', 'hooks.json');
188
+ if (fs.existsSync(hooksFile))
189
+ return true;
190
+ const hooksDir = path.join(root, 'hooks');
191
+ if (fs.existsSync(hooksDir)) {
192
+ try {
193
+ if (fs.readdirSync(hooksDir).some((entry) => !entry.startsWith('.')))
194
+ return true;
195
+ }
196
+ catch {
197
+ return true;
198
+ }
199
+ }
200
+ const settingsFile = path.join(root, 'settings.json');
201
+ if (!fs.existsSync(settingsFile))
202
+ return false;
203
+ try {
204
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
205
+ return Object.keys(settings).some((key) => key !== 'permissions') || 'permissions' in settings;
206
+ }
207
+ catch {
208
+ return true;
209
+ }
210
+ }
176
211
  /**
177
212
  * Remove the enabledPlugins key for this plugin. Inverse of enablePluginInSettings.
178
213
  */
@@ -9,16 +9,30 @@
9
9
  * contents into agent version homes.
10
10
  */
11
11
  import type { AgentId, DiscoveredPlugin, PluginManifest } from './types.js';
12
+ export interface PluginCapabilities {
13
+ hasHooks: boolean;
14
+ hasMcp: boolean;
15
+ hasBin: boolean;
16
+ hasScripts: boolean;
17
+ hasSettings: boolean;
18
+ hasPermissions: boolean;
19
+ }
20
+ export declare const PLUGIN_EXEC_SURFACE_LABELS: Record<keyof PluginCapabilities, string>;
12
21
  /**
13
22
  * Discover all plugins in ~/.agents/plugins/.
14
23
  * A valid plugin has a .claude-plugin/plugin.json manifest.
15
24
  */
16
25
  export declare function discoverPlugins(): DiscoveredPlugin[];
17
26
  export declare function buildDiscoveredPlugin(pluginRoot: string, manifest: PluginManifest): DiscoveredPlugin;
27
+ export declare function inspectPluginCapabilities(pluginRoot: string): PluginCapabilities;
28
+ export declare function hasPluginExecSurfaces(capabilities: PluginCapabilities): boolean;
29
+ export declare function pluginCapabilityLabels(capabilities: PluginCapabilities): string[];
18
30
  /**
19
31
  * Load a plugin manifest from a plugin directory.
20
32
  */
21
33
  export declare function loadPluginManifest(pluginRoot: string): PluginManifest | null;
34
+ export declare function validatePluginName(name: string): boolean;
35
+ export declare function assertPluginTargetContained(targetRoot: string, pluginsDir: string): void;
22
36
  /**
23
37
  * Get a specific plugin by name.
24
38
  */
@@ -79,7 +93,10 @@ export declare function checkPluginDependencies(manifest: PluginManifest): strin
79
93
  * native install path and is marked enabled — see
80
94
  * https://code.claude.com/docs/en/plugins.
81
95
  */
82
- export declare function syncPluginToVersion(plugin: DiscoveredPlugin, agent: AgentId, versionHome: string): {
96
+ export declare function syncPluginToVersion(plugin: DiscoveredPlugin, agent: AgentId, versionHome: string, options?: {
97
+ allowExecSurfaces?: boolean;
98
+ version?: string;
99
+ }): {
83
100
  success: boolean;
84
101
  skills: string[];
85
102
  commands: string[];
@@ -152,6 +169,7 @@ export declare function installPlugin(spec: string): Promise<{
152
169
  name: string;
153
170
  root: string;
154
171
  isNew: boolean;
172
+ capabilities: PluginCapabilities;
155
173
  }>;
156
174
  /**
157
175
  * Update an installed plugin by re-pulling from its original source.
@@ -10,15 +10,24 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
- import { execSync } from 'child_process';
13
+ import { execFileSync } from 'child_process';
14
14
  import { getPluginsDir, getTrashPluginsDir } from './state.js';
15
15
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
16
16
  import { AGENTS, PLUGINS_CAPABLE_AGENTS } from './agents.js';
17
+ import { shouldInstallCommandAsSkill, installCommandSkillToVersion } from './command-skills.js';
17
18
  import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, enablePluginInSettings, disablePluginInSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, } from './plugin-marketplace.js';
18
19
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
19
20
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
20
21
  const USER_CONFIG_FILE = '.user-config.json';
21
22
  const SOURCE_FILE = '.source';
23
+ export const PLUGIN_EXEC_SURFACE_LABELS = {
24
+ hasHooks: 'hooks/',
25
+ hasMcp: '.mcp.json',
26
+ hasBin: 'bin/',
27
+ hasScripts: 'scripts/',
28
+ hasSettings: 'settings.json',
29
+ hasPermissions: 'permissions/',
30
+ };
22
31
  /**
23
32
  * Discover all plugins in ~/.agents/plugins/.
24
33
  * A valid plugin has a .claude-plugin/plugin.json manifest.
@@ -59,6 +68,26 @@ export function buildDiscoveredPlugin(pluginRoot, manifest) {
59
68
  hasSettings: pluginHasNonPermissionSettings(pluginRoot),
60
69
  };
61
70
  }
71
+ export function inspectPluginCapabilities(pluginRoot) {
72
+ const manifest = loadPluginManifest(pluginRoot);
73
+ const plugin = manifest ? buildDiscoveredPlugin(pluginRoot, manifest) : null;
74
+ return {
75
+ hasHooks: (plugin?.hooks.length || 0) > 0 || pluginHasDirectoryEntries(pluginRoot, 'hooks'),
76
+ hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')),
77
+ hasBin: (plugin?.bin.length || 0) > 0,
78
+ hasScripts: (plugin?.scripts.length || 0) > 0,
79
+ hasSettings: pluginHasNonPermissionSettings(pluginRoot),
80
+ hasPermissions: pluginHasPermissionsPath(pluginRoot),
81
+ };
82
+ }
83
+ export function hasPluginExecSurfaces(capabilities) {
84
+ return Object.values(capabilities).some(Boolean);
85
+ }
86
+ export function pluginCapabilityLabels(capabilities) {
87
+ return Object.keys(PLUGIN_EXEC_SURFACE_LABELS)
88
+ .filter((key) => capabilities[key])
89
+ .map((key) => PLUGIN_EXEC_SURFACE_LABELS[key]);
90
+ }
62
91
  /**
63
92
  * Load a plugin manifest from a plugin directory.
64
93
  */
@@ -72,7 +101,7 @@ export function loadPluginManifest(pluginRoot) {
72
101
  const parsed = JSON.parse(content);
73
102
  if (!parsed.name || !parsed.version)
74
103
  return null;
75
- if (/[/\\]/.test(parsed.name) || parsed.name.includes('..') || parsed.name.includes('\0')) {
104
+ if (!validatePluginName(parsed.name)) {
76
105
  return null;
77
106
  }
78
107
  return parsed;
@@ -81,6 +110,20 @@ export function loadPluginManifest(pluginRoot) {
81
110
  return null;
82
111
  }
83
112
  }
113
+ export function validatePluginName(name) {
114
+ return name.length > 0
115
+ && !/[/\\]/.test(name)
116
+ && !name.includes('..')
117
+ && !name.includes('\0');
118
+ }
119
+ export function assertPluginTargetContained(targetRoot, pluginsDir) {
120
+ const resolvedPluginsDir = path.resolve(pluginsDir);
121
+ const resolvedTargetRoot = path.resolve(targetRoot);
122
+ if (resolvedTargetRoot !== resolvedPluginsDir
123
+ && !resolvedTargetRoot.startsWith(`${resolvedPluginsDir}${path.sep}`)) {
124
+ throw new Error(`Plugin install target escapes plugins directory: ${targetRoot}`);
125
+ }
126
+ }
84
127
  /**
85
128
  * Get a specific plugin by name.
86
129
  */
@@ -279,7 +322,7 @@ export function checkPluginDependencies(manifest) {
279
322
  * native install path and is marked enabled — see
280
323
  * https://code.claude.com/docs/en/plugins.
281
324
  */
282
- export function syncPluginToVersion(plugin, agent, versionHome) {
325
+ export function syncPluginToVersion(plugin, agent, versionHome, options = {}) {
283
326
  const result = {
284
327
  success: false,
285
328
  skills: [],
@@ -302,10 +345,36 @@ export function syncPluginToVersion(plugin, agent, versionHome) {
302
345
  if (Object.keys(userConfig).length > 0) {
303
346
  expandUserConfigInDir(installDir, userConfig);
304
347
  }
348
+ // 2b. Write agent-native manifest dir alongside .claude-plugin/ when the agent
349
+ // expects a different directory name (e.g. Codex uses .codex-plugin/).
350
+ const agentManifestDir = AGENTS[agent].pluginManifestDir;
351
+ if (agentManifestDir && agentManifestDir !== PLUGIN_MANIFEST_DIR) {
352
+ const srcManifest = path.join(installDir, PLUGIN_MANIFEST_DIR, PLUGIN_MANIFEST_FILE);
353
+ if (fs.existsSync(srcManifest)) {
354
+ const destManifestDir = path.join(installDir, agentManifestDir);
355
+ fs.mkdirSync(destManifestDir, { recursive: true });
356
+ fs.copyFileSync(srcManifest, path.join(destManifestDir, PLUGIN_MANIFEST_FILE));
357
+ }
358
+ }
305
359
  // 3-5. Synthesize manifest, register marketplace, enable plugin.
306
360
  syncMarketplaceManifest(agent, versionHome);
307
361
  registerMarketplace(agent, versionHome);
308
- enablePluginInSettings(plugin.name, agent, versionHome);
362
+ enablePluginInSettings(plugin.name, agent, versionHome, {
363
+ allowExecSurfaces: options.allowExecSurfaces === true,
364
+ });
365
+ // 5b. Convert plugin commands/ to skills for agents that dropped command support
366
+ // (Codex >= 0.117.0). Skill name is prefixed with plugin name to avoid
367
+ // collision with standalone command skills.
368
+ if (options.version && shouldInstallCommandAsSkill(agent, options.version) && plugin.commands.length > 0) {
369
+ const agentDir = path.join(versionHome, `.${AGENTS[agent].id}`);
370
+ const skillSourceDirs = [path.join(agentDir, 'skills')];
371
+ for (const cmd of plugin.commands) {
372
+ const srcPath = path.join(plugin.root, 'commands', `${cmd}.md`);
373
+ if (fs.existsSync(srcPath)) {
374
+ installCommandSkillToVersion(agentDir, `${plugin.name}-${cmd}`, srcPath, skillSourceDirs);
375
+ }
376
+ }
377
+ }
309
378
  // 6. Migrate legacy dual-dash flat layout from previous versions of agents-cli.
310
379
  migrateLegacyFlatLayout(plugin, agent, versionHome);
311
380
  // Populate the result shape for backward-compatible callers/reporting.
@@ -321,7 +390,13 @@ export function syncPluginToVersion(plugin, agent, versionHome) {
321
390
  return result;
322
391
  }
323
392
  function pluginHasPermissions(plugin) {
324
- const settingsPath = path.join(plugin.root, 'settings.json');
393
+ return pluginHasPermissionsPath(plugin.root);
394
+ }
395
+ function pluginHasPermissionsPath(pluginRoot) {
396
+ const permissionsDir = path.join(pluginRoot, 'permissions');
397
+ if (fs.existsSync(permissionsDir))
398
+ return true;
399
+ const settingsPath = path.join(pluginRoot, 'settings.json');
325
400
  if (!fs.existsSync(settingsPath))
326
401
  return false;
327
402
  try {
@@ -332,6 +407,17 @@ function pluginHasPermissions(plugin) {
332
407
  return false;
333
408
  }
334
409
  }
410
+ function pluginHasDirectoryEntries(pluginRoot, dirName) {
411
+ const dir = path.join(pluginRoot, dirName);
412
+ if (!fs.existsSync(dir))
413
+ return false;
414
+ try {
415
+ return fs.readdirSync(dir).some((entry) => !entry.startsWith('.'));
416
+ }
417
+ catch {
418
+ return false;
419
+ }
420
+ }
335
421
  /**
336
422
  * Walk a directory and replace ${user_config.*} placeholders in text files.
337
423
  * Leaves all other variables (${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}) alone.
@@ -846,7 +932,11 @@ export async function installPlugin(spec) {
846
932
  targetName = path.basename(resolvedSource).replace(/\.git$/, '');
847
933
  }
848
934
  }
935
+ if (!validatePluginName(targetName)) {
936
+ throw new Error(`Invalid plugin name: ${targetName}`);
937
+ }
849
938
  const targetRoot = path.join(pluginsDir, targetName);
939
+ assertPluginTargetContained(targetRoot, pluginsDir);
850
940
  const isNew = !fs.existsSync(targetRoot);
851
941
  if (isLocalPath) {
852
942
  // Copy local directory
@@ -860,7 +950,7 @@ export async function installPlugin(spec) {
860
950
  if (fs.existsSync(targetRoot)) {
861
951
  fs.rmSync(targetRoot, { recursive: true, force: true });
862
952
  }
863
- execSync(`git clone --depth 1 ${JSON.stringify(resolvedSource)} ${JSON.stringify(targetRoot)}`, {
953
+ execFileSync('git', ['clone', '--depth', '1', resolvedSource, targetRoot], {
864
954
  stdio: 'pipe',
865
955
  });
866
956
  }
@@ -870,9 +960,10 @@ export async function installPlugin(spec) {
870
960
  fs.rmSync(targetRoot, { recursive: true, force: true });
871
961
  throw new Error(`Installed source has no valid .claude-plugin/plugin.json`);
872
962
  }
963
+ const capabilities = inspectPluginCapabilities(targetRoot);
873
964
  // Persist source for future updates
874
965
  fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath }), 'utf-8');
875
- return { name: manifest.name, root: targetRoot, isNew };
966
+ return { name: manifest.name, root: targetRoot, isNew, capabilities };
876
967
  }
877
968
  /**
878
969
  * Update an installed plugin by re-pulling from its original source.
@@ -896,7 +987,7 @@ export async function updatePlugin(name) {
896
987
  }
897
988
  try {
898
989
  if (sourceInfo.isGit) {
899
- execSync(`git -C ${JSON.stringify(plugin.root)} pull --ff-only`, { stdio: 'pipe' });
990
+ execFileSync('git', ['-C', plugin.root, 'pull', '--ff-only'], { stdio: 'pipe' });
900
991
  }
901
992
  else {
902
993
  const resolvedSource = sourceInfo.source.replace(/^~/, process.env.HOME || '~');
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared redaction helpers for text that may be exported or logged.
3
+ */
4
+ export declare function redactSecrets(text: string): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared redaction helpers for text that may be exported or logged.
3
+ */
4
+ const SECRET_PATTERNS = [
5
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED_AWS_KEY]'],
6
+ [/\bghp_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
7
+ [/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
8
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
9
+ [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
10
+ [/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
11
+ ];
12
+ export function redactSecrets(text) {
13
+ let safe = text;
14
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
15
+ safe = safe.replace(pattern, replacement);
16
+ }
17
+ return safe;
18
+ }
@@ -6,6 +6,8 @@
6
6
  * with transport, runtime, and argument metadata.
7
7
  */
8
8
  import type { RegistryType, RegistryConfig, McpServerEntry, SkillEntry, RegistrySearchResult, ResolvedPackage } from './types.js';
9
+ export declare function validatedNpmSpec(spec: string): string;
10
+ export declare function validatedPyPISpec(spec: string): string;
9
11
  /** Get all registries of a given type, merging defaults with user overrides. */
10
12
  export declare function getRegistries(type: RegistryType): Record<string, RegistryConfig>;
11
13
  /** Get only the enabled registries of a given type. */
@@ -8,6 +8,21 @@
8
8
  import * as fs from 'fs';
9
9
  import { DEFAULT_REGISTRIES } from './types.js';
10
10
  import { readMeta, writeMeta } from './state.js';
11
+ const UNSAFE_PACKAGE_SPEC_CHARS = /[;&|`$\s\x00-\x1f\x7f]/;
12
+ const NPM_SPEC_PATTERN = /^(@[a-z0-9][a-z0-9-_.]*\/)?[a-z0-9][a-z0-9-_.]*(@[A-Za-z0-9._+-]+)?$/;
13
+ const PYPI_SPEC_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,_-]+\])?(==[A-Za-z0-9._-]+)?$/;
14
+ export function validatedNpmSpec(spec) {
15
+ if (spec.length > 214 || UNSAFE_PACKAGE_SPEC_CHARS.test(spec) || !NPM_SPEC_PATTERN.test(spec)) {
16
+ throw new Error(`Invalid npm package spec: ${spec}`);
17
+ }
18
+ return spec;
19
+ }
20
+ export function validatedPyPISpec(spec) {
21
+ if (UNSAFE_PACKAGE_SPEC_CHARS.test(spec) || !PYPI_SPEC_PATTERN.test(spec)) {
22
+ throw new Error(`Invalid PyPI package spec: ${spec}`);
23
+ }
24
+ return spec;
25
+ }
11
26
  /** Get all registries of a given type, merging defaults with user overrides. */
12
27
  export function getRegistries(type) {
13
28
  const meta = readMeta();
@@ -11,7 +11,15 @@ import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
13
  import { getRoutinesDir } from './state.js';
14
- const REAL_HOME = os.homedir();
14
+ function resolveRealHome() {
15
+ const home = os.homedir();
16
+ try {
17
+ return fs.realpathSync(home);
18
+ }
19
+ catch {
20
+ return home;
21
+ }
22
+ }
15
23
  /** Environment variables forwarded from the parent process into the sandbox. */
16
24
  const ENV_ALLOWLIST = [
17
25
  'PATH',
@@ -93,8 +101,9 @@ export function cleanJobHome(name) {
93
101
  }
94
102
  /** Symlink allowed directories into the overlay HOME, skipping paths outside the real HOME. */
95
103
  export function symlinkAllowedDirs(overlayHome, dirs) {
104
+ const realHome = resolveRealHome();
96
105
  for (const dir of dirs) {
97
- const expanded = dir.replace(/^~/, REAL_HOME);
106
+ const expanded = dir.replace(/^~/, realHome);
98
107
  // Resolve .. and symlinks to prevent path traversal outside HOME
99
108
  let realPath;
100
109
  try {
@@ -105,10 +114,10 @@ export function symlinkAllowedDirs(overlayHome, dirs) {
105
114
  // Directory doesn't exist yet — resolve .. components without following symlinks
106
115
  realPath = path.resolve(expanded);
107
116
  }
108
- if (!realPath.startsWith(REAL_HOME + path.sep) && realPath !== REAL_HOME) {
117
+ if (!realPath.startsWith(realHome + path.sep) && realPath !== realHome) {
109
118
  continue;
110
119
  }
111
- const relativePath = path.relative(REAL_HOME, realPath);
120
+ const relativePath = path.relative(realHome, realPath);
112
121
  const symlinkTarget = path.join(overlayHome, relativePath);
113
122
  const parentDir = path.dirname(symlinkTarget);
114
123
  fs.mkdirSync(parentDir, { recursive: true });
@@ -122,6 +131,7 @@ export function symlinkAllowedDirs(overlayHome, dirs) {
122
131
  }
123
132
  /** Generate a Claude settings.json in the overlay with scoped permissions from the job config. */
124
133
  export function generateClaudeConfig(overlayHome, config) {
134
+ const realHome = resolveRealHome();
125
135
  const claudeDir = path.join(overlayHome, '.claude');
126
136
  fs.mkdirSync(claudeDir, { recursive: true });
127
137
  const allowPermissions = [];
@@ -152,7 +162,7 @@ export function generateClaudeConfig(overlayHome, config) {
152
162
  // Scope filesystem tools to allowed dirs
153
163
  if (config.allow?.dirs) {
154
164
  for (const dir of config.allow.dirs) {
155
- const resolved = dir.replace(/^~/, REAL_HOME);
165
+ const resolved = dir.replace(/^~/, realHome);
156
166
  // Read always granted for allowed dirs
157
167
  allowPermissions.push(`Read(${resolved}/**)`);
158
168
  if (config.mode === 'edit') {
@@ -40,9 +40,15 @@ export interface SecretsBundle {
40
40
  /** Optional per-var metadata, keyed by var name (parallel to `vars`). */
41
41
  meta?: Record<string, VarMeta>;
42
42
  }
43
+ export interface LegacyBundleCandidate {
44
+ name: string;
45
+ file: string;
46
+ keys: string[];
47
+ }
43
48
  export declare const RESERVED_ENV_NAMES: Set<string>;
44
49
  export declare function bundleToEnvPrefix(name: string): string;
45
50
  export declare function isReservedEnvName(key: string): boolean;
51
+ export declare function isLoaderOrInterpreterEnv(name: string): boolean;
46
52
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
47
53
  export declare function validateBundleName(name: string): void;
48
54
  export declare function validateEnvKey(key: string): void;
@@ -107,16 +113,5 @@ export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
107
113
  item: string;
108
114
  }>;
109
115
  export declare function parseDotenv(content: string): Record<string, string>;
110
- /**
111
- * One-shot migration: move legacy YAML bundles into the keychain. Scans both
112
- * `~/.agents/secrets/` and `~/.agents-system/secrets/` — past versions of the
113
- * CLI sometimes wrote bundles into the system repo even though that's never
114
- * been a legitimate location. After migration the directories are removed so
115
- * the system repo never carries a `secrets/` subdir again.
116
- *
117
- * Idempotent: re-runs after the dirs are gone are no-ops. Called eagerly at
118
- * the top of every `agents secrets` subcommand. Skipped on the latency-
119
- * sensitive `agents run` path.
120
- */
121
- export declare function migrateLegacyBundles(): void;
116
+ export declare function migrateLegacyBundles(confirmBundle: (candidate: LegacyBundleCandidate) => boolean | Promise<boolean>): Promise<number>;
122
117
  export type { SecretRef };
@@ -43,7 +43,24 @@ export function bundleToEnvPrefix(name) {
43
43
  return name.replace(/[-\.]/g, '_').toUpperCase();
44
44
  }
45
45
  export function isReservedEnvName(key) {
46
- return RESERVED_ENV_NAMES.has(key);
46
+ return RESERVED_ENV_NAMES.has(key.toUpperCase());
47
+ }
48
+ export function isLoaderOrInterpreterEnv(name) {
49
+ const upper = name.toUpperCase();
50
+ return upper.startsWith('LD_') ||
51
+ upper.startsWith('DYLD_') ||
52
+ [
53
+ 'NODE_OPTIONS',
54
+ 'PYTHONPATH',
55
+ 'PYTHONSTARTUP',
56
+ 'BASH_ENV',
57
+ 'ENV',
58
+ 'PERL5OPT',
59
+ 'RUBYOPT',
60
+ 'PROMPT_COMMAND',
61
+ 'IFS',
62
+ 'CDPATH',
63
+ ].includes(upper);
47
64
  }
48
65
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
49
66
  export function validateBundleName(name) {
@@ -55,6 +72,9 @@ export function validateEnvKey(key) {
55
72
  if (!ENV_KEY_PATTERN.test(key)) {
56
73
  throw new Error(`Invalid environment variable name '${key}'. Must match [A-Za-z_][A-Za-z0-9_]*.`);
57
74
  }
75
+ if (isLoaderOrInterpreterEnv(key) || isReservedEnvName(key)) {
76
+ throw new Error(`Env key "${key}" is reserved — cannot be used in a secrets bundle. Reserved keys include PATH, HOME, USER, and dynamic-loader/interpreter vars (LD_*, DYLD_*, NODE_OPTIONS, etc.).`);
77
+ }
58
78
  }
59
79
  /** Assert that `t` is one of the known SECRET_TYPES. Throws with the allowed list otherwise. */
60
80
  export function validateSecretType(t) {
@@ -396,18 +416,7 @@ export function parseDotenv(content) {
396
416
  }
397
417
  return out;
398
418
  }
399
- /**
400
- * One-shot migration: move legacy YAML bundles into the keychain. Scans both
401
- * `~/.agents/secrets/` and `~/.agents-system/secrets/` — past versions of the
402
- * CLI sometimes wrote bundles into the system repo even though that's never
403
- * been a legitimate location. After migration the directories are removed so
404
- * the system repo never carries a `secrets/` subdir again.
405
- *
406
- * Idempotent: re-runs after the dirs are gone are no-ops. Called eagerly at
407
- * the top of every `agents secrets` subcommand. Skipped on the latency-
408
- * sensitive `agents run` path.
409
- */
410
- export function migrateLegacyBundles() {
419
+ export async function migrateLegacyBundles(confirmBundle) {
411
420
  const home = os.homedir();
412
421
  const dirs = [
413
422
  path.join(home, '.agents', 'secrets'),
@@ -426,26 +435,35 @@ export function migrateLegacyBundles() {
426
435
  for (const entry of ymls) {
427
436
  const file = path.join(dir, entry);
428
437
  const name = entry.replace(/\.(yml|yaml)$/, '');
438
+ let parsed;
429
439
  try {
430
440
  validateBundleName(name);
431
441
  const raw = fs.readFileSync(file, 'utf-8');
432
- const parsed = yaml.parse(raw);
433
- if (!parsed || typeof parsed !== 'object')
434
- continue;
435
- const bundle = {
436
- name,
437
- description: parsed.description,
438
- allow_exec: Boolean(parsed.allow_exec),
439
- icloud_sync: Boolean(parsed.icloud_sync),
440
- vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
441
- };
442
- writeBundle(bundle);
443
- fs.unlinkSync(file);
444
- migrated++;
442
+ parsed = yaml.parse(raw);
445
443
  }
446
444
  catch {
447
445
  // Leave malformed YAMLs in place so the user can inspect them.
446
+ continue;
447
+ }
448
+ if (!parsed || typeof parsed !== 'object')
449
+ continue;
450
+ const bundle = {
451
+ name,
452
+ description: parsed.description,
453
+ allow_exec: Boolean(parsed.allow_exec),
454
+ icloud_sync: Boolean(parsed.icloud_sync),
455
+ vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
456
+ };
457
+ const keys = Object.keys(bundle.vars);
458
+ for (const key of keys) {
459
+ validateEnvKey(key);
448
460
  }
461
+ const proceed = await confirmBundle({ name, file, keys });
462
+ if (!proceed)
463
+ continue;
464
+ writeBundle(bundle);
465
+ fs.unlinkSync(file);
466
+ migrated++;
449
467
  }
450
468
  try {
451
469
  if (fs.readdirSync(dir).length === 0)
@@ -453,7 +471,5 @@ export function migrateLegacyBundles() {
453
471
  }
454
472
  catch { /* not empty or already gone */ }
455
473
  }
456
- if (migrated > 0) {
457
- console.log(`Migrated ${migrated} legacy bundle${migrated === 1 ? '' : 's'} into keychain.`);
458
- }
474
+ return migrated;
459
475
  }
@@ -78,9 +78,9 @@ export function setKeychainBackendForTest(b) {
78
78
  backend = b;
79
79
  return prev;
80
80
  }
81
- // Backend routing: non-sync items go through /usr/bin/security so they share
82
- // an ACL identity with items created by previous CLI versions (no prompts on
83
- // existing data). Sync items must go through the signed .app — only the .app
81
+ // Backend routing: non-sync items go through /usr/bin/security with an empty
82
+ // trusted-app ACL; existing items written by older versions retain their ACL.
83
+ // Sync items must go through the signed .app — only the .app
84
84
  // holds the keychain-access-groups entitlement macOS requires for
85
85
  // kSecAttrSynchronizable. Enumeration also goes through the .app because the
86
86
  // security CLI doesn't expose listing by service prefix.
@@ -166,7 +166,7 @@ export function setKeychainToken(item, value, sync = false) {
166
166
  }
167
167
  // `security -i` keeps the value out of argv (and `ps`).
168
168
  const user = os.userInfo().username;
169
- const cmd = `add-generic-password -a ${quoteForSecurityCli(user)} -s ${quoteForSecurityCli(item)} -w ${quoteForSecurityCli(value)} -U\n`;
169
+ const cmd = `add-generic-password -a ${quoteForSecurityCli(user)} -s ${quoteForSecurityCli(item)} -w ${quoteForSecurityCli(value)} -T ${quoteForSecurityCli('')} -U\n`;
170
170
  const result = spawnSync('security', ['-i'], {
171
171
  input: cmd,
172
172
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -10,6 +10,8 @@
10
10
  * GET /api/v1/cloud-runs/:id/session.jsonl → raw captured jsonl
11
11
  */
12
12
  import type { SessionMeta } from './types.js';
13
+ export declare function assertContained(candidate: string, rootDir: string): string;
14
+ export declare function validateCloudExecutionId(executionId: string): string;
13
15
  /**
14
16
  * List cloud executions the user has captured sessions for. Includes
15
17
  * completed + needs_review + failed; an empty session_path means capture