@the-open-engine/zeroshot 6.10.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-open-engine/zeroshot",
3
- "version": "6.10.1",
3
+ "version": "6.10.2",
4
4
  "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -19,6 +19,7 @@ const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.
19
19
  const { loadSettings } = require('../../lib/settings.js');
20
20
  const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
21
21
  const { prependWorktreeToolBinToEnv } = require('../worktree-tooling-env.js');
22
+ const { applyDarwinKeychainBoundaryToEnv } = require('../darwin-keychain-boundary.js');
22
23
  const {
23
24
  CLAUDE_SETTINGS_ENV,
24
25
  cleanupClaudeSettingsOverlay,
@@ -689,7 +690,10 @@ function buildFinalContext({ agent, context, desiredOutputFormat, runOutputForma
689
690
  }
690
691
 
691
692
  function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
692
- const { claudeSettingsPath = null } = options;
693
+ const {
694
+ claudeSettingsPath = null,
695
+ applyDarwinKeychainBoundary = applyDarwinKeychainBoundaryToEnv,
696
+ } = options;
693
697
  const spawnEnv = { ...process.env };
694
698
  const agentCwd = agent.config?.cwd || agent.worktree?.path || process.cwd();
695
699
  const clusterId = agent.cluster?.id || agent.cluster_id || process.env.ZEROSHOT_CLUSTER_ID;
@@ -724,6 +728,13 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
724
728
  }
725
729
  }
726
730
 
731
+ // KEYCHAIN BOUNDARY (darwin only): non-interactive local/worktree worker
732
+ // descendants must not reach the user's GUI Keychain session (issue #704).
733
+ // Docker isolation never reaches buildSpawnEnv (see spawnClaudeTaskIsolated).
734
+ // Applied before the worktree tool bins so repo-managed tool substitutes
735
+ // stay first on PATH.
736
+ applyDarwinKeychainBoundary(spawnEnv);
737
+
727
738
  prependWorktreeToolBinToEnv(spawnEnv, {
728
739
  cwd: agentCwd,
729
740
  worktreePath: agent.worktree?.path || null,
@@ -822,14 +833,7 @@ function createPendingTaskLaunchHandle({
822
833
  return handle;
823
834
  }
824
835
 
825
- function spawnTaskProcess({
826
- agent,
827
- ctPath,
828
- args,
829
- cwd,
830
- spawnEnv,
831
- spawnTimeoutMs = 30000,
832
- }) {
836
+ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs = 30000 }) {
833
837
  // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
834
838
  const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
835
839
  // spawn() throws on null bytes in argv; strip them before they get there.
@@ -878,10 +882,7 @@ function spawnTaskProcess({
878
882
  const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
879
883
  const rejectWithOwnership = async (error) => {
880
884
  const classifiedError = classifyCleanupOwnership(error);
881
- if (
882
- !callerOwnsCommandCleanup(classifiedError) &&
883
- agent.currentTask === pendingLaunch
884
- ) {
885
+ if (!callerOwnsCommandCleanup(classifiedError) && agent.currentTask === pendingLaunch) {
885
886
  let termination;
886
887
  try {
887
888
  termination = await pendingLaunch.kill(classifiedError.message);
@@ -2198,7 +2199,6 @@ function startIsolatedTail({ agent, manager, clusterId, logFilePath, state, onLi
2198
2199
  });
2199
2200
  }
2200
2201
 
2201
-
2202
2202
  async function checkIsolatedStatus({
2203
2203
  agent,
2204
2204
  manager,
@@ -2628,6 +2628,7 @@ module.exports = {
2628
2628
  ensureAskUserQuestionHook,
2629
2629
  ensureDangerousGitHook,
2630
2630
  resolveMcpConfigArgs,
2631
+ buildSpawnEnv,
2631
2632
  spawnClaudeTask,
2632
2633
  spawnTaskProcess,
2633
2634
  followClaudeTaskLogs,
@@ -12,6 +12,7 @@ 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 { applyDarwinKeychainBoundaryToEnv } = require('./darwin-keychain-boundary');
15
16
  const { getTask, getTaskBySpawnOwnershipToken } = require('../task-lib/store.js');
16
17
  const {
17
18
  TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
@@ -151,6 +152,7 @@ class ClaudeTaskRunner extends TaskRunner {
151
152
  * @param {boolean} [options.quiet] - Suppress console logging
152
153
  * @param {number} [options.timeout] - Task timeout in ms (default: 1 hour)
153
154
  * @param {Function} [options.onOutput] - Callback for output lines
155
+ * @param {Function} [options.applyDarwinKeychainBoundary] - Boundary injection seam for tests
154
156
  */
155
157
  constructor(options = {}) {
156
158
  super();
@@ -158,6 +160,8 @@ class ClaudeTaskRunner extends TaskRunner {
158
160
  this.quiet = options.quiet || false;
159
161
  this.timeout = options.timeout || 60 * 60 * 1000;
160
162
  this.onOutput = options.onOutput || null;
163
+ this.applyDarwinKeychainBoundary =
164
+ options.applyDarwinKeychainBoundary || applyDarwinKeychainBoundaryToEnv;
161
165
  }
162
166
 
163
167
  /**
@@ -376,19 +380,30 @@ class ClaudeTaskRunner extends TaskRunner {
376
380
  const spawnEnv = {
377
381
  ...process.env,
378
382
  };
383
+ let claudeSettingsPath = null;
379
384
  if (providerName === 'claude' && resolvedModelSpec?.model) {
380
385
  spawnEnv.ANTHROPIC_MODEL = resolvedModelSpec.model;
381
386
  }
382
387
  if (providerName === 'claude') {
383
- spawnEnv[CLAUDE_SETTINGS_ENV] = prepareClaudeSettingsOverlay({
388
+ claudeSettingsPath = prepareClaudeSettingsOverlay({
384
389
  includeDangerousGit: Boolean(worktreePath),
385
390
  });
391
+ spawnEnv[CLAUDE_SETTINGS_ENV] = claudeSettingsPath;
386
392
  const mcpConfigPath = resolveRepoMcpConfigPath({ cwd, worktreePath });
387
393
  if (mcpConfigPath) {
388
394
  spawnEnv[CLAUDE_MCP_CONFIG_ENV] = mcpConfigPath;
389
395
  }
390
396
  }
391
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
+
392
407
  prependWorktreeToolBinToEnv(spawnEnv, { cwd, worktreePath });
393
408
 
394
409
  return spawnEnv;
@@ -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
+ };
@@ -6,6 +6,9 @@ const DEFAULT_TOOL_BIN_RELATIVE_PATHS = ['.zeroshot/bin', '.worktree-tool-bin'];
6
6
  const FALLBACK_BIN_PREFIX = '.worktree-tool-bin.';
7
7
 
8
8
  function pathKeyForEnv(env) {
9
+ if (Object.prototype.hasOwnProperty.call(env, 'PATH')) {
10
+ return 'PATH';
11
+ }
9
12
  return Object.keys(env).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
10
13
  }
11
14