@the-open-engine/zeroshot 6.10.0 → 6.10.2

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 (33) hide show
  1. package/cli/index.js +16 -0
  2. package/cluster-hooks/block-ask-user-question.py +2 -3
  3. package/cluster-hooks/block-dangerous-git.py +2 -3
  4. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  5. package/lib/agent-cli-provider/adapters/claude.js +42 -1
  6. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  7. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/contract-options.js +2 -0
  9. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  10. package/lib/agent-cli-provider/types.d.ts +6 -2
  11. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/types.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/agent/agent-lifecycle.js +13 -6
  15. package/src/agent/agent-task-executor.js +527 -313
  16. package/src/agent-cli-provider/adapters/claude.ts +46 -1
  17. package/src/agent-cli-provider/contract-options.ts +6 -0
  18. package/src/agent-cli-provider/types.ts +9 -6
  19. package/src/claude-task-runner.js +168 -44
  20. package/src/darwin-keychain-boundary.js +174 -0
  21. package/src/task-spawn-cleanup-ownership.js +82 -0
  22. package/src/worktree-claude-config.js +193 -85
  23. package/src/worktree-tooling-env.js +3 -0
  24. package/task-lib/attachable-watcher.js +93 -267
  25. package/task-lib/command-spec-cleanup.js +219 -0
  26. package/task-lib/commands/clean.js +35 -5
  27. package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
  28. package/task-lib/commands/kill.js +117 -4
  29. package/task-lib/commands/status.js +1 -0
  30. package/task-lib/runner.js +61 -4
  31. package/task-lib/store.js +68 -6
  32. package/task-lib/watcher-output-runtime.js +284 -0
  33. package/task-lib/watcher.js +124 -257
@@ -1,4 +1,5 @@
1
1
  import { getString, isRecord, stringifyJson, tryParseJson } from '../json';
2
+ import { contractError } from '../contract-errors';
2
3
  import {
3
4
  type BuildProviderCommandOptions,
4
5
  type ClaudeCliFeatures,
@@ -66,6 +67,10 @@ function detectCliFeatures(helpText?: string | null): ClaudeCliFeatures {
66
67
  supportsVerbose: unknown ? true : /--verbose/.test(help),
67
68
  supportsModel: unknown ? true : /--model/.test(help),
68
69
  supportsEffort: unknown ? true : /--effort/.test(help),
70
+ // Settings carry mandatory safety hooks, so an unprobed or old CLI must
71
+ // fail closed before a provider process is spawned.
72
+ supportsSettings: !unknown && /--settings/.test(help),
73
+ supportsMcpConfig: !unknown && /--mcp-config/.test(help),
69
74
  supportsResume: unknown ? true : /--resume/.test(help),
70
75
  unknown,
71
76
  };
@@ -131,6 +136,40 @@ function addSessionArgs(args: string[], options: BuildProviderCommandOptions): v
131
136
  }
132
137
  }
133
138
 
139
+ function addSettingsArgs(args: string[], options: BuildProviderCommandOptions): void {
140
+ const settingsPath = options.claudeSettingsFile?.trim();
141
+ if (settingsPath) {
142
+ args.push('--settings', settingsPath);
143
+ }
144
+ }
145
+
146
+ function addMcpConfigArgs(args: string[], options: BuildProviderCommandOptions): void {
147
+ if (!options.mcpConfig?.length) return;
148
+ args.push('--mcp-config', ...options.mcpConfig);
149
+ }
150
+
151
+ function failClosedUnsupportedRunConfig(options: BuildProviderCommandOptions): void {
152
+ const features = optionFeatures(options);
153
+ if (options.claudeSettingsFile?.trim() && features.supportsSettings === false) {
154
+ throw contractError({
155
+ code: 'invalid-field',
156
+ field: 'options.cliFeatures.supportsSettings',
157
+ exitCode: 2,
158
+ message:
159
+ 'Claude CLI does not advertise --settings support required for Zeroshot safety hooks. Upgrade Claude Code before running this task.',
160
+ });
161
+ }
162
+ if (options.mcpConfig?.length && features.supportsMcpConfig === false) {
163
+ throw contractError({
164
+ code: 'invalid-field',
165
+ field: 'options.cliFeatures.supportsMcpConfig',
166
+ exitCode: 2,
167
+ message:
168
+ 'Claude CLI does not advertise --mcp-config support required to preserve repository MCP tools. Upgrade Claude Code before running this task.',
169
+ });
170
+ }
171
+ }
172
+
134
173
  function extractSessionId(line: string): string | null {
135
174
  const event = tryParseJson(line.trim());
136
175
  if (!isRecord(event)) return null;
@@ -185,15 +224,21 @@ function collectWarnings(options: BuildProviderCommandOptions): WarningMetadata[
185
224
  }
186
225
 
187
226
  function buildCommand(context: string, options: BuildProviderCommandOptions = {}): CommandSpec {
227
+ failClosedUnsupportedRunConfig(options);
188
228
  const { command, args: commandPrefix } = resolveClaudeCommand();
189
- const args: string[] = [...commandPrefix, '--print', '--input-format', 'text'];
229
+ const args: string[] = [...commandPrefix, '--print'];
190
230
  const authEnv = options.authEnv ?? {};
191
231
 
232
+ // --mcp-config is variadic. A following option terminates its values before
233
+ // the positional prompt.
234
+ addMcpConfigArgs(args, options);
235
+ args.push('--input-format', 'text');
192
236
  addOutputArgs(args, options);
193
237
  addSchemaArgs(args, options);
194
238
  addModelArgs(args, options);
195
239
  addAutoApproveArgs(args, options);
196
240
  addSessionArgs(args, options);
241
+ addSettingsArgs(args, options);
197
242
 
198
243
  args.push(context);
199
244
 
@@ -23,6 +23,7 @@ const CLI_FEATURE_FIELDS = [
23
23
  'supportsVerbose',
24
24
  'supportsModel',
25
25
  'supportsEffort',
26
+ 'supportsSettings',
26
27
  'supportsJson',
27
28
  'supportsOutputSchema',
28
29
  'supportsDir',
@@ -209,6 +210,11 @@ function normalizeBuildOptions(value: Record<string, unknown>): BuildProviderCom
209
210
  'continueSession',
210
211
  optionalBooleanValue(value.continueSession, 'options.continueSession')
211
212
  );
213
+ addDefined(
214
+ result,
215
+ 'claudeSettingsFile',
216
+ optionalStringValue(value.claudeSettingsFile, 'options.claudeSettingsFile')
217
+ );
212
218
  addDefined(result, 'cliFeatures', optionalCliFeatures(value.cliFeatures));
213
219
  addDefined(result, 'mcpConfig', optionalMcpConfig(value.mcpConfig));
214
220
  addDefined(
@@ -101,6 +101,8 @@ export interface ClaudeCliFeatures extends BaseCliFeatures {
101
101
  readonly supportsVerbose: boolean;
102
102
  readonly supportsModel: boolean;
103
103
  readonly supportsEffort: boolean;
104
+ readonly supportsSettings: boolean;
105
+ readonly supportsMcpConfig: boolean;
104
106
  readonly supportsResume: boolean;
105
107
  }
106
108
 
@@ -195,6 +197,7 @@ export interface CliFeatureOverrides {
195
197
  readonly supportsVerbose?: boolean;
196
198
  readonly supportsModel?: boolean;
197
199
  readonly supportsEffort?: boolean;
200
+ readonly supportsSettings?: boolean;
198
201
  readonly supportsJson?: boolean;
199
202
  readonly supportsOutputSchema?: boolean;
200
203
  readonly supportsDir?: boolean;
@@ -231,10 +234,10 @@ export interface CliFeatureOverrides {
231
234
  }
232
235
 
233
236
  export interface CleanupMetadata {
234
- readonly kind: 'temp-file';
237
+ readonly kind: 'temp-file' | 'temp-directory';
235
238
  readonly provider: ProviderId;
236
239
  readonly path: string;
237
- readonly reason: 'output-schema';
240
+ readonly reason: 'output-schema' | 'settings-overlay';
238
241
  }
239
242
 
240
243
  export interface WarningMetadata {
@@ -268,14 +271,14 @@ export interface BuildProviderCommandOptions {
268
271
  readonly autoApprove?: boolean;
269
272
  readonly resumeSessionId?: string;
270
273
  readonly continueSession?: boolean;
274
+ readonly claudeSettingsFile?: string;
271
275
  readonly cliFeatures?: CliFeatureOverrides;
272
276
  readonly authEnv?: Readonly<Record<string, string>>;
273
277
  readonly strictSchema?: boolean;
274
278
  readonly gateway?: GatewayBuildOptions;
275
- // MCP server configs forwarded to providers that accept an MCP config CLI flag (currently
276
- // Copilot's `--additional-mcp-config`). Each entry is an inline JSON string (the standard
277
- // `{"mcpServers": {...}}` envelope) or an `@<path>` file reference; adapters emit one flag per
278
- // entry. Providers whose adapter models no MCP flag ignore this field.
279
+ // MCP server configs forwarded to providers that accept an MCP config CLI flag. Claude accepts
280
+ // one variadic `--mcp-config` followed by file paths; adapters such as Copilot accept repeated
281
+ // flags with inline JSON so configs survive container path translation.
279
282
  readonly mcpConfig?: readonly string[];
280
283
  }
281
284
 
@@ -12,7 +12,24 @@ const { loadSettings } = require('../lib/settings');
12
12
  const { normalizeProviderName } = require('../lib/provider-names');
13
13
  const { getProvider } = require('./providers');
14
14
  const { prependWorktreeToolBinToEnv } = require('./worktree-tooling-env');
15
- const { prepareClaudeConfigDir } = require('./worktree-claude-config');
15
+ const { applyDarwinKeychainBoundaryToEnv } = require('./darwin-keychain-boundary');
16
+ const { getTask, getTaskBySpawnOwnershipToken } = require('../task-lib/store.js');
17
+ const {
18
+ TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
19
+ cleanupCallerOwnedCommand,
20
+ callerOwnsCommandCleanup,
21
+ createTaskSpawnOwnershipToken,
22
+ requireTaskIdFromWrapperResult,
23
+ trackTaskWrapperCleanupOwnership,
24
+ } = require('./task-spawn-cleanup-ownership');
25
+ const {
26
+ CLAUDE_MCP_CONFIG_ENV,
27
+ CLAUDE_SETTINGS_ENV,
28
+ cleanupClaudeSettingsOverlay,
29
+ prepareClaudeSettingsOverlay,
30
+ resolveContainerMcpConfigPath,
31
+ resolveRepoMcpConfigPath,
32
+ } = require('./worktree-claude-config');
16
33
  const {
17
34
  appendTaskRunModelArgs,
18
35
  wrapTaskRunWithIsolatedSettings,
@@ -80,6 +97,28 @@ function runCommand(command, args, options = {}, callback = null) {
80
97
  });
81
98
  }
82
99
 
100
+ const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale', 'cancelled']);
101
+
102
+ async function cleanupPersistedTaskAfterLaunchFailure(ctPath, taskId) {
103
+ let lastError = null;
104
+ for (let attempt = 1; attempt <= 3; attempt++) {
105
+ let commandError = null;
106
+ try {
107
+ await runCommand(ctPath, ['kill', taskId], { timeout: 10000 });
108
+ } catch (error) {
109
+ commandError = error;
110
+ }
111
+ const task = getTask(taskId);
112
+ if (!task || (TASK_TERMINAL_STATUSES.has(task.status) && !task.commandCleanup)) {
113
+ return;
114
+ }
115
+ lastError =
116
+ commandError ||
117
+ new Error(`Task ${taskId} termination and command cleanup were not confirmed`);
118
+ }
119
+ throw lastError || new Error(`Task ${taskId} cleanup failed`);
120
+ }
121
+
83
122
  function runCommandSync(command, args, options = {}) {
84
123
  const timeout = options.timeout ?? 30000;
85
124
  const result = spawnSync(command, args, { ...options, timeout });
@@ -95,6 +134,17 @@ function runCommandSync(command, args, options = {}) {
95
134
  return result.stdout?.toString() || '';
96
135
  }
97
136
 
137
+ function appendIsolatedMcpConfigArgs(command, provider, options) {
138
+ if (provider !== 'claude') return;
139
+ const mcpConfigPath = resolveContainerMcpConfigPath({
140
+ cwd: options.cwd || process.cwd(),
141
+ worktreePath: options.worktreePath || null,
142
+ });
143
+ if (mcpConfigPath) {
144
+ command.push('--mcp-config', mcpConfigPath);
145
+ }
146
+ }
147
+
98
148
  class ClaudeTaskRunner extends TaskRunner {
99
149
  /**
100
150
  * @param {Object} options
@@ -102,6 +152,7 @@ class ClaudeTaskRunner extends TaskRunner {
102
152
  * @param {boolean} [options.quiet] - Suppress console logging
103
153
  * @param {number} [options.timeout] - Task timeout in ms (default: 1 hour)
104
154
  * @param {Function} [options.onOutput] - Callback for output lines
155
+ * @param {Function} [options.applyDarwinKeychainBoundary] - Boundary injection seam for tests
105
156
  */
106
157
  constructor(options = {}) {
107
158
  super();
@@ -109,6 +160,8 @@ class ClaudeTaskRunner extends TaskRunner {
109
160
  this.quiet = options.quiet || false;
110
161
  this.timeout = options.timeout || 60 * 60 * 1000;
111
162
  this.onOutput = options.onOutput || null;
163
+ this.applyDarwinKeychainBoundary =
164
+ options.applyDarwinKeychainBoundary || applyDarwinKeychainBoundaryToEnv;
112
165
  }
113
166
 
114
167
  /**
@@ -191,14 +244,20 @@ class ClaudeTaskRunner extends TaskRunner {
191
244
  worktreePath,
192
245
  });
193
246
 
194
- const taskId = await this._spawnAndGetTaskId(ctPath, args, cwd, spawnEnv, agentId);
247
+ let taskId;
248
+ try {
249
+ taskId = await this._spawnAndGetTaskId(ctPath, args, cwd, spawnEnv, agentId);
250
+ } catch (error) {
251
+ cleanupCallerOwnedCommand(error, () =>
252
+ cleanupClaudeSettingsOverlay(spawnEnv[CLAUDE_SETTINGS_ENV])
253
+ );
254
+ throw error;
255
+ }
195
256
 
257
+ // Once a task ID is returned, the detached watcher owns provider
258
+ // termination and command cleanup.
196
259
  this._log(`📋 [${agentId}]: Following zeroshot logs for ${taskId}`);
197
-
198
- // Wait for task registration
199
260
  await this._waitForTaskReady(ctPath, taskId);
200
-
201
- // Follow logs until completion
202
261
  return this._followLogs(ctPath, taskId, agentId);
203
262
  }
204
263
 
@@ -321,16 +380,30 @@ class ClaudeTaskRunner extends TaskRunner {
321
380
  const spawnEnv = {
322
381
  ...process.env,
323
382
  };
383
+ let claudeSettingsPath = null;
324
384
  if (providerName === 'claude' && resolvedModelSpec?.model) {
325
385
  spawnEnv.ANTHROPIC_MODEL = resolvedModelSpec.model;
326
386
  }
327
387
  if (providerName === 'claude') {
328
- const claudeConfigDir = prepareClaudeConfigDir({ cwd, worktreePath });
329
- if (claudeConfigDir) {
330
- spawnEnv.CLAUDE_CONFIG_DIR = claudeConfigDir;
388
+ claudeSettingsPath = prepareClaudeSettingsOverlay({
389
+ includeDangerousGit: Boolean(worktreePath),
390
+ });
391
+ spawnEnv[CLAUDE_SETTINGS_ENV] = claudeSettingsPath;
392
+ const mcpConfigPath = resolveRepoMcpConfigPath({ cwd, worktreePath });
393
+ if (mcpConfigPath) {
394
+ spawnEnv[CLAUDE_MCP_CONFIG_ENV] = mcpConfigPath;
331
395
  }
332
396
  }
333
397
 
398
+ // KEYCHAIN BOUNDARY (darwin only): keep non-interactive worker descendants
399
+ // away from the user's GUI Keychain session (issue #704).
400
+ try {
401
+ this.applyDarwinKeychainBoundary(spawnEnv);
402
+ } catch (error) {
403
+ if (claudeSettingsPath) cleanupClaudeSettingsOverlay(claudeSettingsPath);
404
+ throw error;
405
+ }
406
+
334
407
  prependWorktreeToolBinToEnv(spawnEnv, { cwd, worktreePath });
335
408
 
336
409
  return spawnEnv;
@@ -345,16 +418,49 @@ class ClaudeTaskRunner extends TaskRunner {
345
418
  * @returns {Promise<string>}
346
419
  */
347
420
  _spawnAndGetTaskId(ctPath, args, cwd, spawnEnv, _agentId) {
421
+ const ownershipToken = createTaskSpawnOwnershipToken();
422
+ const findPersistedTaskId = () => getTaskBySpawnOwnershipToken(ownershipToken)?.id || null;
348
423
  return new Promise((resolve, reject) => {
349
424
  const proc = spawn(ctPath, args, {
350
425
  cwd,
351
426
  stdio: ['ignore', 'pipe', 'pipe'],
352
- env: spawnEnv,
427
+ env: { ...spawnEnv, [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken },
353
428
  windowsHide: true,
354
429
  });
355
430
 
356
431
  let stdout = '';
357
432
  let stderr = '';
433
+ let settled = false;
434
+ const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
435
+ const rejectWithOwnership = async (error) => {
436
+ if (settled) return;
437
+ settled = true;
438
+ const classifiedError = classifyCleanupOwnership(error);
439
+ if (!callerOwnsCommandCleanup(classifiedError)) {
440
+ classifiedError.spawnOwnershipToken = ownershipToken;
441
+ let persistedTaskId = null;
442
+ let lookupError = null;
443
+ try {
444
+ persistedTaskId = findPersistedTaskId();
445
+ } catch (lookupFailure) {
446
+ lookupError = lookupFailure;
447
+ }
448
+ classifiedError.taskId = persistedTaskId;
449
+ try {
450
+ if (lookupError) throw lookupError;
451
+ if (persistedTaskId) {
452
+ await cleanupPersistedTaskAfterLaunchFailure(ctPath, persistedTaskId);
453
+ }
454
+ } catch (cleanupError) {
455
+ classifiedError.message += ` Task cleanup was not confirmed: ${cleanupError.message}`;
456
+ classifiedError.permanent = true;
457
+ classifiedError.restartExhausted = true;
458
+ classifiedError.terminationExhausted = true;
459
+ classifiedError.terminationAttempts = persistedTaskId ? 3 : 1;
460
+ }
461
+ }
462
+ reject(classifiedError);
463
+ };
358
464
 
359
465
  proc.stdout.on('data', (data) => {
360
466
  stdout += data.toString();
@@ -364,21 +470,26 @@ class ClaudeTaskRunner extends TaskRunner {
364
470
  stderr += data.toString();
365
471
  });
366
472
 
367
- proc.on('close', (code) => {
368
- if (code === 0) {
369
- const match = stdout.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/);
370
- if (match) {
371
- resolve(match[1]);
372
- } else {
373
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
374
- }
375
- } else {
376
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
473
+ proc.on('close', async (code) => {
474
+ if (settled) return;
475
+ try {
476
+ const taskId = requireTaskIdFromWrapperResult({
477
+ code,
478
+ stdout,
479
+ stderr,
480
+ parseTaskId: (output) =>
481
+ output.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/)?.[1],
482
+ persistedTaskId: findPersistedTaskId(),
483
+ });
484
+ settled = true;
485
+ resolve(taskId);
486
+ } catch (error) {
487
+ await rejectWithOwnership(error);
377
488
  }
378
489
  });
379
490
 
380
- proc.on('error', (error) => {
381
- reject(error);
491
+ proc.on('error', async (error) => {
492
+ await rejectWithOwnership(error);
382
493
  });
383
494
  });
384
495
  }
@@ -424,6 +535,7 @@ class ClaudeTaskRunner extends TaskRunner {
424
535
  let statusCheckInterval = null;
425
536
  let resolved = false;
426
537
  let lineBuffer = '';
538
+ let cleanupRecoveryPending = false;
427
539
 
428
540
  // Get log file path
429
541
  try {
@@ -558,33 +670,43 @@ class ClaudeTaskRunner extends TaskRunner {
558
670
 
559
671
  statusCheckInterval = setInterval(() => {
560
672
  runCommand(ctPath, ['status', taskId], {}, (error, stdout) => {
561
- if (resolved) return;
562
-
563
- if (
564
- !error &&
565
- (stdout.includes('Status: completed') || stdout.includes('Status: failed'))
566
- ) {
567
- const success = stdout.includes('Status: completed');
673
+ if (resolved || error) return;
674
+ const terminalMatch = stdout.match(/Status:\s+(completed|failed)/i);
675
+ if (!terminalMatch) return;
676
+ if (/Cleanup:\s+pending/i.test(stdout)) {
677
+ if (!cleanupRecoveryPending) {
678
+ cleanupRecoveryPending = true;
679
+ runCommand(ctPath, ['kill', taskId], { timeout: 10000 }, (cleanupError) => {
680
+ cleanupRecoveryPending = false;
681
+ if (cleanupError) {
682
+ this._log(
683
+ `⚠️ [${agentId}]: Terminal cleanup recovery will retry: ${cleanupError.message}`
684
+ );
685
+ }
686
+ });
687
+ }
688
+ return;
689
+ }
568
690
 
569
- pollLogFile();
691
+ const success = terminalMatch[1].toLowerCase() === 'completed';
692
+ pollLogFile();
570
693
 
571
- setTimeout(() => {
572
- if (resolved) return;
573
- resolved = true;
694
+ setTimeout(() => {
695
+ if (resolved) return;
696
+ resolved = true;
574
697
 
575
- if (pollInterval) clearInterval(pollInterval);
576
- if (statusCheckInterval) clearInterval(statusCheckInterval);
698
+ clearInterval(pollInterval);
699
+ clearInterval(statusCheckInterval);
577
700
 
578
- const errorContext = extractErrorContext(success, stdout);
701
+ const errorContext = extractErrorContext(success, stdout);
579
702
 
580
- resolve({
581
- success,
582
- output,
583
- error: errorContext,
584
- taskId,
585
- });
586
- }, 500);
587
- }
703
+ resolve({
704
+ success,
705
+ output,
706
+ error: errorContext,
707
+ taskId,
708
+ });
709
+ }, 500);
588
710
  });
589
711
  }, 1000);
590
712
 
@@ -664,6 +786,8 @@ class ClaudeTaskRunner extends TaskRunner {
664
786
  command.push('--json-schema', JSON.stringify(jsonSchema));
665
787
  }
666
788
 
789
+ appendIsolatedMcpConfigArgs(command, provider, options);
790
+
667
791
  let finalContext = context;
668
792
  if (jsonSchema && desiredOutputFormat === 'json' && runOutputFormat === 'stream-json') {
669
793
  finalContext += `\n\n## Output Format (REQUIRED)\n\nReturn a JSON object that matches this schema exactly.\n\nSchema:\n\`\`\`json\n${JSON.stringify(
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Darwin worker Keychain boundary (issue #704).
3
+ *
4
+ * Non-interactive local/worktree workers are spawned with the host environment,
5
+ * so worker descendants (e.g. `claude doctor` probing Keychain writes through
6
+ * `security -i`) reach the logged-in user's GUI Keychain session and launch
7
+ * SecurityAgent dialogs from a supposedly non-interactive cluster.
8
+ *
9
+ * On darwin, worker spawn envs get a managed shim directory prepended to PATH
10
+ * containing a `security` wrapper that fails closed on interactive invocations
11
+ * (`-i`, `-p`, or no arguments) with a deterministic diagnostic, and execs the
12
+ * real /usr/bin/security for every other subcommand so provider authentication
13
+ * (e.g. `security find-generic-password`) keeps working.
14
+ *
15
+ * Docker isolation never reaches this code path, and non-darwin platforms are
16
+ * left untouched. Set ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN=1 to opt out.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { randomUUID } = require('node:crypto');
23
+
24
+ const SHIM_DIR_RELATIVE_PATH = path.join('.zeroshot', 'keychain-shim');
25
+ const REAL_SECURITY_PATH = '/usr/bin/security';
26
+ const OPT_OUT_ENV_VAR = 'ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN';
27
+
28
+ function shellQuote(value) {
29
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
30
+ }
31
+
32
+ function buildSecurityShimScript(realSecurityPath) {
33
+ return `#!/bin/sh
34
+ # Managed by Zeroshot (src/darwin-keychain-boundary.js). Do not edit.
35
+ #
36
+ # Non-interactive Zeroshot workers must not open the logged-in user's GUI
37
+ # Keychain session (SecurityAgent). Interactive \`security\` invocations fail
38
+ # closed here; every other subcommand is passed through to the real binary so
39
+ # provider authentication keeps working.
40
+
41
+ REAL_SECURITY=${shellQuote(realSecurityPath)}
42
+
43
+ if [ "\${${OPT_OUT_ENV_VAR}:-0}" = "1" ]; then
44
+ exec "$REAL_SECURITY" "$@"
45
+ fi
46
+
47
+ fail_closed() {
48
+ echo "zeroshot: blocked interactive 'security' invocation from a non-interactive worker (argv: $*)." >&2
49
+ echo "zeroshot: this cluster has no interactive Keychain session, so SecurityAgent prompts are disabled." >&2
50
+ echo "zeroshot: run the cluster with Docker isolation or configure explicit credentials for the tool that attempted Keychain access." >&2
51
+ echo "zeroshot: set ${OPT_OUT_ENV_VAR}=1 to restore interactive Keychain access." >&2
52
+ exit 1
53
+ }
54
+
55
+ # \`security\` without arguments enters interactive mode.
56
+ [ "$#" -eq 0 ] && fail_closed
57
+
58
+ # Global options precede the subcommand. -i (interactive) and -p (prompt,
59
+ # implies -i) must not reach the real binary; option letters may be bundled
60
+ # (e.g. -qi). Scanning stops at the first non-option token (the subcommand).
61
+ for arg in "$@"; do
62
+ case "$arg" in
63
+ -*i*|-*p*) fail_closed "$@" ;;
64
+ -*) ;;
65
+ *) break ;;
66
+ esac
67
+ done
68
+
69
+ exec "$REAL_SECURITY" "$@"
70
+ `;
71
+ }
72
+
73
+ /**
74
+ * Create (or refresh) the managed shim directory containing the `security`
75
+ * wrapper. Idempotent: the script is only rewritten when its content changes.
76
+ *
77
+ * @param {object} [options]
78
+ * @param {string} [options.shimBaseDir] - Shim directory (tests only); defaults to ~/.zeroshot/keychain-shim.
79
+ * @param {string} [options.realSecurityPath] - Real binary to exec (tests only); defaults to /usr/bin/security.
80
+ * @returns {string} Absolute path of the shim directory.
81
+ */
82
+ function ensureDarwinKeychainShimDir(options = {}) {
83
+ const shimDir = options.shimBaseDir || path.join(os.homedir(), SHIM_DIR_RELATIVE_PATH);
84
+ const script = buildSecurityShimScript(options.realSecurityPath || REAL_SECURITY_PATH);
85
+ const shimPath = path.join(shimDir, 'security');
86
+
87
+ fs.mkdirSync(shimDir, { recursive: true });
88
+
89
+ let existing = null;
90
+ try {
91
+ existing = fs.readFileSync(shimPath, 'utf8');
92
+ } catch {
93
+ // Missing or unreadable: (re)write below.
94
+ }
95
+ if (existing !== script) {
96
+ const tempPath = path.join(shimDir, `.security.${process.pid}.${randomUUID()}.tmp`);
97
+ try {
98
+ fs.writeFileSync(tempPath, script, { mode: 0o755, flag: 'wx' });
99
+ // The creation mode is subject to umask. Set the final mode before the
100
+ // rename so the live path is never observable as non-executable.
101
+ fs.chmodSync(tempPath, 0o755);
102
+ fs.renameSync(tempPath, shimPath);
103
+ } catch (error) {
104
+ try {
105
+ // Remove any unpublished partial file without masking the publication
106
+ // failure that caused this cleanup path.
107
+ fs.rmSync(tempPath, { force: true });
108
+ } catch (cleanupError) {
109
+ error.message += ` Cleanup also failed: ${cleanupError.message}.`;
110
+ }
111
+ throw error;
112
+ }
113
+ } else {
114
+ // An existing matching shim may have drifted permissions.
115
+ fs.chmodSync(shimPath, 0o755);
116
+ }
117
+
118
+ return shimDir;
119
+ }
120
+
121
+ /**
122
+ * Prepend the Keychain boundary shim to a worker spawn env's PATH.
123
+ *
124
+ * No-op off darwin and when the operator opted out via
125
+ * ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN=1. Fails closed (throws) when the shim
126
+ * cannot be installed: spawning the worker anyway would silently re-expose the
127
+ * interactive Keychain session.
128
+ *
129
+ * @param {object} env - Spawn env to mutate (also returned).
130
+ * @param {object} [options]
131
+ * @param {string} [options.platform] - Platform override (tests only).
132
+ * @param {string} [options.shimBaseDir] - See ensureDarwinKeychainShimDir.
133
+ * @param {string} [options.realSecurityPath] - See ensureDarwinKeychainShimDir.
134
+ * @returns {object} The same env object.
135
+ */
136
+ function applyDarwinKeychainBoundaryToEnv(env, options = {}) {
137
+ const platform = options.platform || process.platform;
138
+ if (platform !== 'darwin') {
139
+ return env;
140
+ }
141
+ if (env[OPT_OUT_ENV_VAR] === '1' || process.env[OPT_OUT_ENV_VAR] === '1') {
142
+ return env;
143
+ }
144
+
145
+ let shimDir;
146
+ try {
147
+ shimDir = ensureDarwinKeychainShimDir(options);
148
+ } catch (error) {
149
+ throw new Error(
150
+ `Failed to install the darwin Keychain boundary shim: ${error.message}. ` +
151
+ `Non-interactive workers must not reach the interactive Keychain session; ` +
152
+ `use Docker isolation or set ${OPT_OUT_ENV_VAR}=1 to opt out.`
153
+ );
154
+ }
155
+
156
+ // Darwin environment keys are case-sensitive: descendants consult PATH,
157
+ // never a differently-cased key such as Path. Preserve empty components
158
+ // because POSIX interprets them as the current directory. An absent PATH
159
+ // retains Node/libuv's default Unix search path after the shim, while an
160
+ // explicitly empty PATH retains its empty component (`<shim>:`).
161
+ const existingEntries =
162
+ env.PATH === undefined
163
+ ? ['/usr/bin', '/bin']
164
+ : String(env.PATH)
165
+ .split(path.delimiter)
166
+ .filter((entry) => entry !== shimDir);
167
+ env.PATH = [shimDir, ...existingEntries].join(path.delimiter);
168
+ return env;
169
+ }
170
+
171
+ module.exports = {
172
+ applyDarwinKeychainBoundaryToEnv,
173
+ ensureDarwinKeychainShimDir,
174
+ };