|
19 | 19 | const fs = require('fs'); |
20 | 20 | const os = require('os'); |
21 | 21 | const path = require('path'); |
| 22 | +const { randomUUID } = require('node:crypto'); |
22 | 23 |
|
23 | 24 | const SHIM_DIR_RELATIVE_PATH = path.join('.zeroshot', 'keychain-shim'); |
24 | 25 | const REAL_SECURITY_PATH = '/usr/bin/security'; |
25 | 26 | const OPT_OUT_ENV_VAR = 'ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN'; |
26 | 27 |
|
27 | | -function pathKeyForEnv(env) { |
28 | | - return Object.keys(env).find((key) => key.toUpperCase() === 'PATH') || 'PATH'; |
29 | | -} |
30 | | - |
31 | 28 | function shellQuote(value) { |
32 | 29 | return `'${String(value).replace(/'/g, `'\\''`)}'`; |
33 | 30 | } |
@@ -96,10 +93,27 @@ function ensureDarwinKeychainShimDir(options = {}) { |
96 | 93 | // Missing or unreadable: (re)write below. |
97 | 94 | } |
98 | 95 | 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); |
100 | 116 | } |
101 | | - // writeFileSync's mode only applies on creation; enforce it unconditionally. |
102 | | - fs.chmodSync(shimPath, 0o755); |
103 | 117 |
|
104 | 118 | return shimDir; |
105 | 119 | } |
@@ -139,11 +153,18 @@ function applyDarwinKeychainBoundaryToEnv(env, options = {}) { |
139 | 153 | ); |
140 | 154 | } |
141 | 155 |
|
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); |
147 | 168 | return env; |
148 | 169 | } |
149 | 170 |
|
|
0 commit comments