Skip to content

Commit c892e4a

Browse files
committed
fix(agent): harden darwin keychain boundary
1 parent c38d050 commit c892e4a

4 files changed

Lines changed: 243 additions & 65 deletions

File tree

src/agent/agent-task-executor.js

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,10 @@ function buildFinalContext({ agent, context, desiredOutputFormat, runOutputForma
690690
}
691691

692692
function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
693-
const { claudeSettingsPath = null } = options;
693+
const {
694+
claudeSettingsPath = null,
695+
applyDarwinKeychainBoundary = applyDarwinKeychainBoundaryToEnv,
696+
} = options;
694697
const spawnEnv = { ...process.env };
695698
const agentCwd = agent.config?.cwd || agent.worktree?.path || process.cwd();
696699
const clusterId = agent.cluster?.id || agent.cluster_id || process.env.ZEROSHOT_CLUSTER_ID;
@@ -730,7 +733,7 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
730733
// Docker isolation never reaches buildSpawnEnv (see spawnClaudeTaskIsolated).
731734
// Applied before the worktree tool bins so repo-managed tool substitutes
732735
// stay first on PATH.
733-
applyDarwinKeychainBoundaryToEnv(spawnEnv);
736+
applyDarwinKeychainBoundary(spawnEnv);
734737

735738
prependWorktreeToolBinToEnv(spawnEnv, {
736739
cwd: agentCwd,
@@ -830,14 +833,7 @@ function createPendingTaskLaunchHandle({
830833
return handle;
831834
}
832835

833-
function spawnTaskProcess({
834-
agent,
835-
ctPath,
836-
args,
837-
cwd,
838-
spawnEnv,
839-
spawnTimeoutMs = 30000,
840-
}) {
836+
function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv, spawnTimeoutMs = 30000 }) {
841837
// Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it.
842838
const SPAWN_TIMEOUT_MS = spawnTimeoutMs;
843839
// spawn() throws on null bytes in argv; strip them before they get there.
@@ -886,10 +882,7 @@ function spawnTaskProcess({
886882
const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
887883
const rejectWithOwnership = async (error) => {
888884
const classifiedError = classifyCleanupOwnership(error);
889-
if (
890-
!callerOwnsCommandCleanup(classifiedError) &&
891-
agent.currentTask === pendingLaunch
892-
) {
885+
if (!callerOwnsCommandCleanup(classifiedError) && agent.currentTask === pendingLaunch) {
893886
let termination;
894887
try {
895888
termination = await pendingLaunch.kill(classifiedError.message);
@@ -2206,7 +2199,6 @@ function startIsolatedTail({ agent, manager, clusterId, logFilePath, state, onLi
22062199
});
22072200
}
22082201

2209-
22102202
async function checkIsolatedStatus({
22112203
agent,
22122204
manager,

src/claude-task-runner.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,16 @@ class ClaudeTaskRunner extends TaskRunner {
152152
* @param {boolean} [options.quiet] - Suppress console logging
153153
* @param {number} [options.timeout] - Task timeout in ms (default: 1 hour)
154154
* @param {Function} [options.onOutput] - Callback for output lines
155+
* @param {Function} [options.applyDarwinKeychainBoundary] - Boundary injection seam for tests
155156
*/
156157
constructor(options = {}) {
157158
super();
158159
this.messageBus = options.messageBus || null;
159160
this.quiet = options.quiet || false;
160161
this.timeout = options.timeout || 60 * 60 * 1000;
161162
this.onOutput = options.onOutput || null;
163+
this.applyDarwinKeychainBoundary =
164+
options.applyDarwinKeychainBoundary || applyDarwinKeychainBoundaryToEnv;
162165
}
163166

164167
/**
@@ -392,7 +395,7 @@ class ClaudeTaskRunner extends TaskRunner {
392395

393396
// KEYCHAIN BOUNDARY (darwin only): keep non-interactive worker descendants
394397
// away from the user's GUI Keychain session (issue #704).
395-
applyDarwinKeychainBoundaryToEnv(spawnEnv);
398+
this.applyDarwinKeychainBoundary(spawnEnv);
396399

397400
prependWorktreeToolBinToEnv(spawnEnv, { cwd, worktreePath });
398401

src/darwin-keychain-boundary.js

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,12 @@
1919
const fs = require('fs');
2020
const os = require('os');
2121
const path = require('path');
22+
const { randomUUID } = require('node:crypto');
2223

2324
const SHIM_DIR_RELATIVE_PATH = path.join('.zeroshot', 'keychain-shim');
2425
const REAL_SECURITY_PATH = '/usr/bin/security';
2526
const OPT_OUT_ENV_VAR = 'ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN';
2627

27-
function pathKeyForEnv(env) {
28-
return Object.keys(env).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
29-
}
30-
3128
function shellQuote(value) {
3229
return `'${String(value).replace(/'/g, `'\\''`)}'`;
3330
}
@@ -96,10 +93,27 @@ function ensureDarwinKeychainShimDir(options = {}) {
9693
// Missing or unreadable: (re)write below.
9794
}
9895
if (existing !== script) {
99-
fs.writeFileSync(shimPath, script, { mode: 0o755 });
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);
100116
}
101-
// writeFileSync's mode only applies on creation; enforce it unconditionally.
102-
fs.chmodSync(shimPath, 0o755);
103117

104118
return shimDir;
105119
}
@@ -139,11 +153,18 @@ function applyDarwinKeychainBoundaryToEnv(env, options = {}) {
139153
);
140154
}
141155

142-
const pathKey = pathKeyForEnv(env);
143-
const existingEntries = (env[pathKey] || '')
144-
.split(path.delimiter)
145-
.filter((entry) => entry && entry !== shimDir);
146-
env[pathKey] = [shimDir, ...existingEntries].join(path.delimiter);
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+
// becomes only the shim, while an explicitly empty PATH retains its empty
160+
// component after the shim (`<shim>:`).
161+
const existingEntries =
162+
env.PATH === undefined
163+
? []
164+
: String(env.PATH)
165+
.split(path.delimiter)
166+
.filter((entry) => entry !== shimDir);
167+
env.PATH = [shimDir, ...existingEntries].join(path.delimiter);
147168
return env;
148169
}
149170

0 commit comments

Comments
 (0)