Skip to content

Commit c38d050

Browse files
committed
fix(agent): fail closed on interactive Keychain use in darwin workers
Local/worktree workers are spawned with the inherited host environment, so worker descendants (e.g. `claude doctor` probing Keychain writes through `security -i`) reach the logged-in user's GUI Keychain session and launch SecurityAgent dialogs from a supposedly non-interactive cluster. On darwin, worker spawn envs now get a managed shim directory prepended to PATH whose `security` wrapper fails closed on interactive invocations (`-i`, `-p`, or no arguments) with a deterministic diagnostic that points at Docker isolation or explicit credential configuration, and execs /usr/bin/security for every other subcommand so provider authentication (e.g. `security find-generic-password`) keeps working. Docker isolation never reaches buildSpawnEnv and non-darwin platforms are untouched; set ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN=1 to opt out. Fixes #704
1 parent a04552e commit c38d050

4 files changed

Lines changed: 414 additions & 0 deletions

File tree

src/agent/agent-task-executor.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const { getTask, getTaskBySpawnOwnershipToken } = require('../../task-lib/store.
1919
const { loadSettings } = require('../../lib/settings.js');
2020
const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
2121
const { prependWorktreeToolBinToEnv } = require('../worktree-tooling-env.js');
22+
const { applyDarwinKeychainBoundaryToEnv } = require('../darwin-keychain-boundary.js');
2223
const {
2324
CLAUDE_SETTINGS_ENV,
2425
cleanupClaudeSettingsOverlay,
@@ -724,6 +725,13 @@ function buildSpawnEnv(agent, providerName, modelSpec, options = {}) {
724725
}
725726
}
726727

728+
// KEYCHAIN BOUNDARY (darwin only): non-interactive local/worktree worker
729+
// descendants must not reach the user's GUI Keychain session (issue #704).
730+
// Docker isolation never reaches buildSpawnEnv (see spawnClaudeTaskIsolated).
731+
// Applied before the worktree tool bins so repo-managed tool substitutes
732+
// stay first on PATH.
733+
applyDarwinKeychainBoundaryToEnv(spawnEnv);
734+
727735
prependWorktreeToolBinToEnv(spawnEnv, {
728736
cwd: agentCwd,
729737
worktreePath: agent.worktree?.path || null,
@@ -2628,6 +2636,7 @@ module.exports = {
26282636
ensureAskUserQuestionHook,
26292637
ensureDangerousGitHook,
26302638
resolveMcpConfigArgs,
2639+
buildSpawnEnv,
26312640
spawnClaudeTask,
26322641
spawnTaskProcess,
26332642
followClaudeTaskLogs,

src/claude-task-runner.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { loadSettings } = require('../lib/settings');
1212
const { normalizeProviderName } = require('../lib/provider-names');
1313
const { getProvider } = require('./providers');
1414
const { prependWorktreeToolBinToEnv } = require('./worktree-tooling-env');
15+
const { applyDarwinKeychainBoundaryToEnv } = require('./darwin-keychain-boundary');
1516
const { getTask, getTaskBySpawnOwnershipToken } = require('../task-lib/store.js');
1617
const {
1718
TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
@@ -389,6 +390,10 @@ class ClaudeTaskRunner extends TaskRunner {
389390
}
390391
}
391392

393+
// KEYCHAIN BOUNDARY (darwin only): keep non-interactive worker descendants
394+
// away from the user's GUI Keychain session (issue #704).
395+
applyDarwinKeychainBoundaryToEnv(spawnEnv);
396+
392397
prependWorktreeToolBinToEnv(spawnEnv, { cwd, worktreePath });
393398

394399
return spawnEnv;

src/darwin-keychain-boundary.js

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
23+
const SHIM_DIR_RELATIVE_PATH = path.join('.zeroshot', 'keychain-shim');
24+
const REAL_SECURITY_PATH = '/usr/bin/security';
25+
const OPT_OUT_ENV_VAR = 'ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN';
26+
27+
function pathKeyForEnv(env) {
28+
return Object.keys(env).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
29+
}
30+
31+
function shellQuote(value) {
32+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
33+
}
34+
35+
function buildSecurityShimScript(realSecurityPath) {
36+
return `#!/bin/sh
37+
# Managed by Zeroshot (src/darwin-keychain-boundary.js). Do not edit.
38+
#
39+
# Non-interactive Zeroshot workers must not open the logged-in user's GUI
40+
# Keychain session (SecurityAgent). Interactive \`security\` invocations fail
41+
# closed here; every other subcommand is passed through to the real binary so
42+
# provider authentication keeps working.
43+
44+
REAL_SECURITY=${shellQuote(realSecurityPath)}
45+
46+
if [ "\${${OPT_OUT_ENV_VAR}:-0}" = "1" ]; then
47+
exec "$REAL_SECURITY" "$@"
48+
fi
49+
50+
fail_closed() {
51+
echo "zeroshot: blocked interactive 'security' invocation from a non-interactive worker (argv: $*)." >&2
52+
echo "zeroshot: this cluster has no interactive Keychain session, so SecurityAgent prompts are disabled." >&2
53+
echo "zeroshot: run the cluster with Docker isolation or configure explicit credentials for the tool that attempted Keychain access." >&2
54+
echo "zeroshot: set ${OPT_OUT_ENV_VAR}=1 to restore interactive Keychain access." >&2
55+
exit 1
56+
}
57+
58+
# \`security\` without arguments enters interactive mode.
59+
[ "$#" -eq 0 ] && fail_closed
60+
61+
# Global options precede the subcommand. -i (interactive) and -p (prompt,
62+
# implies -i) must not reach the real binary; option letters may be bundled
63+
# (e.g. -qi). Scanning stops at the first non-option token (the subcommand).
64+
for arg in "$@"; do
65+
case "$arg" in
66+
-*i*|-*p*) fail_closed "$@" ;;
67+
-*) ;;
68+
*) break ;;
69+
esac
70+
done
71+
72+
exec "$REAL_SECURITY" "$@"
73+
`;
74+
}
75+
76+
/**
77+
* Create (or refresh) the managed shim directory containing the `security`
78+
* wrapper. Idempotent: the script is only rewritten when its content changes.
79+
*
80+
* @param {object} [options]
81+
* @param {string} [options.shimBaseDir] - Shim directory (tests only); defaults to ~/.zeroshot/keychain-shim.
82+
* @param {string} [options.realSecurityPath] - Real binary to exec (tests only); defaults to /usr/bin/security.
83+
* @returns {string} Absolute path of the shim directory.
84+
*/
85+
function ensureDarwinKeychainShimDir(options = {}) {
86+
const shimDir = options.shimBaseDir || path.join(os.homedir(), SHIM_DIR_RELATIVE_PATH);
87+
const script = buildSecurityShimScript(options.realSecurityPath || REAL_SECURITY_PATH);
88+
const shimPath = path.join(shimDir, 'security');
89+
90+
fs.mkdirSync(shimDir, { recursive: true });
91+
92+
let existing = null;
93+
try {
94+
existing = fs.readFileSync(shimPath, 'utf8');
95+
} catch {
96+
// Missing or unreadable: (re)write below.
97+
}
98+
if (existing !== script) {
99+
fs.writeFileSync(shimPath, script, { mode: 0o755 });
100+
}
101+
// writeFileSync's mode only applies on creation; enforce it unconditionally.
102+
fs.chmodSync(shimPath, 0o755);
103+
104+
return shimDir;
105+
}
106+
107+
/**
108+
* Prepend the Keychain boundary shim to a worker spawn env's PATH.
109+
*
110+
* No-op off darwin and when the operator opted out via
111+
* ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN=1. Fails closed (throws) when the shim
112+
* cannot be installed: spawning the worker anyway would silently re-expose the
113+
* interactive Keychain session.
114+
*
115+
* @param {object} env - Spawn env to mutate (also returned).
116+
* @param {object} [options]
117+
* @param {string} [options.platform] - Platform override (tests only).
118+
* @param {string} [options.shimBaseDir] - See ensureDarwinKeychainShimDir.
119+
* @param {string} [options.realSecurityPath] - See ensureDarwinKeychainShimDir.
120+
* @returns {object} The same env object.
121+
*/
122+
function applyDarwinKeychainBoundaryToEnv(env, options = {}) {
123+
const platform = options.platform || process.platform;
124+
if (platform !== 'darwin') {
125+
return env;
126+
}
127+
if (env[OPT_OUT_ENV_VAR] === '1' || process.env[OPT_OUT_ENV_VAR] === '1') {
128+
return env;
129+
}
130+
131+
let shimDir;
132+
try {
133+
shimDir = ensureDarwinKeychainShimDir(options);
134+
} catch (error) {
135+
throw new Error(
136+
`Failed to install the darwin Keychain boundary shim: ${error.message}. ` +
137+
`Non-interactive workers must not reach the interactive Keychain session; ` +
138+
`use Docker isolation or set ${OPT_OUT_ENV_VAR}=1 to opt out.`
139+
);
140+
}
141+
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);
147+
return env;
148+
}
149+
150+
module.exports = {
151+
applyDarwinKeychainBoundaryToEnv,
152+
ensureDarwinKeychainShimDir,
153+
};

0 commit comments

Comments
 (0)