amicus 4.9.4 → 4.9.6

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.
@@ -42,7 +42,18 @@ const PROVISION_TIMEOUT_MS = 15000;
42
42
  * there is no cache (or the repair defers/contends), we emit a clear notice that
43
43
  * the GUI provisions on first use and that headless runs + the council already
44
44
  * work, then point at `amicus doctor --fix` (#56) — NOT a reinstall, which can
45
- * loop. A short timeout keeps a slow disk from ever hanging the install.
45
+ * loop.
46
+ *
47
+ * WHAT IS ACTUALLY BOUNDED HERE, stated precisely because the old sentence
48
+ * ("a short timeout keeps a slow disk from ever hanging the install") named a
49
+ * bound that does not exist on the default path. `PROVISION_TIMEOUT_MS` reaches
50
+ * `repairElectron` as `timeoutMs`, and `timeoutMs` becomes the DOWNLOAD budget
51
+ * (`electron-provision.js`'s `downloadMs`) — which the cache-only default path
52
+ * never uses, because it never downloads. The extract is what could hang here,
53
+ * and it is bounded by the extractor's own idle and hard caps
54
+ * (`zip-from-buffer.js`: 30 s with no progress, 240 s total), whose live timer
55
+ * handle is also what stops Node exiting 0 in the middle of a stall. Both
56
+ * bounds are real; neither is the other.
46
57
  *
47
58
  * This MUST never throw out of postinstall — the whole body (sync setup, the
48
59
  * awaited repair, and a synchronous-throw resolver) is guarded so nothing here
@@ -58,6 +69,15 @@ const PROVISION_TIMEOUT_MS = 15000;
58
69
  * @param {object} deps - { repairElectron } override for testing.
59
70
  * @returns {Promise<void>}
60
71
  */
72
+ function warnIfUnverified(result) {
73
+ if (!result || !result.unverified) { return; }
74
+ console.warn('[amicus] Note: the Electron GUI binary was installed UNVERIFIED — either no published sha256');
75
+ console.warn('[amicus] covered this artifact, so its bytes were checked only against whatever the mirror');
76
+ console.warn('[amicus] served, or its sha256 CONTRADICTED the published one and');
77
+ console.warn('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON accepted it anyway.');
78
+ console.warn('[amicus] See docs/troubleshooting.md (Electron artifact REFUSED).');
79
+ }
80
+
61
81
  async function provisionElectron(deps = {}) {
62
82
  try {
63
83
  const _repair = deps.repairElectron || repairElectron;
@@ -65,9 +85,10 @@ async function provisionElectron(deps = {}) {
65
85
  // Opt-in aggressive prewarm (#60): full fetch if needed. Non-fatal.
66
86
  if (process.env.AMICUS_PREFETCH_ELECTRON === '1') {
67
87
  console.log('[amicus] AMICUS_PREFETCH_ELECTRON=1 — prewarming the Electron GUI binary (may download)...');
68
- const forced = await _repair({ force: true });
88
+ const forced = await _repair();
69
89
  if (forced && forced.repaired) {
70
90
  console.log('[amicus] Electron GUI binary prewarmed.');
91
+ warnIfUnverified(forced);
71
92
  return;
72
93
  }
73
94
  if (forced && forced.quarantined) {
@@ -80,7 +101,7 @@ async function provisionElectron(deps = {}) {
80
101
  }
81
102
 
82
103
  const result = await _repair({ cacheOnly: true, timeoutMs: PROVISION_TIMEOUT_MS });
83
- if (result && result.repaired) { return; }
104
+ if (result && result.repaired) { warnIfUnverified(result); return; }
84
105
  // AV quarantine (electron.exe deleted right after extract) needs ACTION, not
85
106
  // a generic "provisions on first use" notice — re-extracting can never win,
86
107
  // so print the allow-list instruction verbatim instead. (No retry loop.)
@@ -89,6 +110,10 @@ async function provisionElectron(deps = {}) {
89
110
  console.warn('[amicus] Headless runs and the council already work without the GUI.');
90
111
  return;
91
112
  }
113
+ // A trust REFUSAL is not the same as "no cache yet": the artifact was found,
114
+ // hashed, and rejected. One line, so the reason is not lost behind the generic
115
+ // notice below (docs/troubleshooting.md promises this line).
116
+ if (result && result.integrity) { console.warn(`[amicus] Note: ${result.reason}`); }
92
117
  // No cache hit (deferred), contended, or otherwise not provisioned now.
93
118
  console.warn('[amicus] Note: the Electron GUI binary is not provisioned yet — it will download on first use of the interactive GUI / setup-wizard.');
94
119
  console.warn(`[amicus] Headless runs and the council already work. To provision the GUI now: ${HINTS.doctorFix}`);
@@ -10,6 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
+ const fsDefault = require('fs');
13
14
  const path = require('path');
14
15
  const os = require('os');
15
16
 
@@ -39,4 +40,43 @@ function resolveCacheRoots(env = process.env) {
39
40
  return [...new Set(roots.filter(Boolean))];
40
41
  }
41
42
 
42
- module.exports = { resolveCacheRoots, defaultCacheRoot };
43
+ /**
44
+ * Locate a previously-downloaded electron zip in the env-configurable cache
45
+ * roots. Walks <root>/<sha>/electron-v<ver>-<platform>-<arch>.zip.
46
+ *
47
+ * MOVED here from electron-install.js (v4.9.6 F1): that file sits at the 300-line
48
+ * gate with no headroom, and the F1 staging wiring had to go somewhere. Cache
49
+ * LOOKUP belongs beside cache-root RESOLUTION anyway; electron-install.js
50
+ * re-exports it so `ei.cachedZip` stays a valid import.
51
+ *
52
+ * The `<sha>` directory names come from `readdirSync` on a directory an attacker
53
+ * may write, so the returned path is attacker-INFLUENCED. Callers must treat it
54
+ * as such: read it ONCE into memory and hash and extract THOSE bytes, never
55
+ * resolving the name a second time (sidecar/electron-custody.js), and never
56
+ * print it unsanitized (utils/text-sanitize.js).
57
+ * @returns {string|null} absolute zip path, or null when no cache hit.
58
+ */
59
+ function cachedZip({ version, platform = process.platform, arch = process.arch, env = process.env, fs = fsDefault } = {}) {
60
+ const zipName = `electron-v${version}-${platform}-${arch}.zip`;
61
+ for (const root of resolveCacheRoots(env)) {
62
+ let shaDirs;
63
+ try {
64
+ shaDirs = fs.readdirSync(root);
65
+ } catch {
66
+ continue;
67
+ }
68
+ for (const sha of shaDirs) {
69
+ const candidate = path.join(root, sha, zipName);
70
+ try {
71
+ if (fs.existsSync(candidate)) {
72
+ return candidate;
73
+ }
74
+ } catch {
75
+ /* ignore unreadable subdir */
76
+ }
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ module.exports = { resolveCacheRoots, defaultCacheRoot, cachedZip };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * CUSTODY of the Electron artifact: one open, one read, one Buffer.
3
+ *
4
+ * THE PROPERTY THIS MODULE EXISTS TO MAKE TRUE, and the only one it claims:
5
+ *
6
+ * **Amicus never itself writes, or reports as verified, bytes it did not hash.**
7
+ *
8
+ * Deliberately NOT "the user launches genuine Electron". A live attacker
9
+ * running as the same uid can overwrite `<electronDir>/dist/electron.exe`
10
+ * directly, at any moment, with no artifact involved at all — so no
11
+ * acquisition-time design can promise that, and claiming it would be the
12
+ * overclaim this whole change exists to stop.
13
+ *
14
+ * WHY A BUFFER, AND NOT A FILE DESCRIPTOR. Two council designs independently
15
+ * probed the fd remedy and both refuted it: a descriptor names an INODE, not a
16
+ * version of an inode. A same-uid `writeFileSync` at the path truncates and
17
+ * rewrites that same inode, and a positional read through our retained fd then
18
+ * returns the attacker's bytes (MEASURED twice, on Windows 11 / NTFS / Node
19
+ * 24.18: `"CLEANCLEANCLEAN"` before, `"POISONPOISONPOI"` after, through the
20
+ * SAME fd). A Buffer is different in kind: once the bytes are in this process's
21
+ * heap, no filesystem write can reach them. That is the whole design.
22
+ *
23
+ * WHY NOT A PRIVATE COPY EITHER — the remedy this replaces. v4.9.6 copied the
24
+ * artifact into a fresh 0700 `mkdtempSync` directory and asserted the attacker
25
+ * had "no name for it and no handle on it". MEASURED false on both halves: a
26
+ * spinner found the fixed `amicus-electron-stage-` prefix on its FIRST readdir
27
+ * of `os.tmpdir()`, opened the copy `r+` as the same user, and overwrote it;
28
+ * and on Windows `mkdtempSync` yields mode 666 while the module skipped its own
29
+ * `chmod(0o700)` on win32, so the 0700 was never even attempted. 0700 excludes
30
+ * OTHER users; the attacker in this threat model is THIS user.
31
+ *
32
+ * WHAT THE THREAT MODEL IS. An attacker who can write the Electron download
33
+ * cache directory, running as the same user as amicus. Out of scope, and stated
34
+ * rather than implied: that same user can also read and write amicus's process
35
+ * memory (`WriteProcessMemory`, or `process_vm_writev` under
36
+ * `yama.ptrace_scope=0`), rewrite amicus's own `node_modules`, or edit its
37
+ * config. Against THAT capability nothing here matters — an attacker in our
38
+ * address space can simply flip the gate's own verdict. This module closes the
39
+ * attacker whose capability is WRITING FILES, which is the one the digest gate
40
+ * makes sense against: a less-trusted cache root — a shared build box, a
41
+ * restored CI cache volume, a container bind-mount — read by a process whose
42
+ * own tree is trusted.
43
+ *
44
+ * NEAR-LEAF MODULE: `fs` + `path`, plus the pure house sanitizer
45
+ * `utils/text-sanitize`. Requires nothing in this cluster, so it can be
46
+ * required from either side of the electron-install -> electron-provision arrow.
47
+ *
48
+ * @module sidecar/electron-custody
49
+ */
50
+
51
+ 'use strict';
52
+
53
+ const fsDefault = require('fs');
54
+ const path = require('path');
55
+
56
+ /** Positional-read chunk. 8 MiB keeps the loop at ~18 reads for a 138 MiB artifact. */
57
+ const READ_CHUNK = 8 * 1024 * 1024;
58
+
59
+ /**
60
+ * An electron artifact larger than this is not an electron artifact. The real
61
+ * win32 x64 artifact measured 138 MiB (144,265,219 bytes); darwin and linux are
62
+ * the same order. The cap is checked against `fstat`'s size BEFORE anything is
63
+ * allocated, so a 4 GiB sparse file planted in the cache costs one `fstat`.
64
+ */
65
+ const MAX_ARTIFACT_BYTES = 1024 * 1024 * 1024;
66
+
67
+ /**
68
+ * The ONLY shape allowed to become a path component:
69
+ * `electron-v<version>-<platform>-<arch>.zip`, with each field restricted to
70
+ * characters an electron version / platform / arch can actually contain.
71
+ *
72
+ * An ALLOW-list on purpose. A deny-list of `..` and separators is the shape
73
+ * that keeps losing — it has to anticipate every dialect (`..`, `%2e%2e`, a
74
+ * bare `\` that only win32's `path` treats as a separator), and it fails open
75
+ * on the one it did not think of. This fails closed on everything it was not
76
+ * written for.
77
+ *
78
+ * MOVED HERE from the deleted `electron-stage.js`, unchanged. It is still
79
+ * load-bearing: `version` is read out of `<electronDir>/package.json` whenever
80
+ * the caller supplies none, and `doctor --fix` — the one production caller —
81
+ * supplies none, for a directory it located by SCANNING npx caches. MEASURED
82
+ * before the check, end to end, with a planted `"version": "43.1.1/../../victim"`:
83
+ * a path two levels outside the intended directory was written and a
84
+ * pre-existing file there was destroyed.
85
+ */
86
+ const ARTIFACT_NAME = /^electron-v[0-9A-Za-z][0-9A-Za-z.+-]*-[0-9A-Za-z_]+-[0-9A-Za-z_]+\.zip$/;
87
+
88
+ /**
89
+ * True when `fileName` is a plain filename amicus itself could have produced.
90
+ *
91
+ * Both halves are checked deliberately. The pattern is the real control; the
92
+ * `path.basename` equality states the property in the platform's OWN dialect,
93
+ * so the claim "this is a filename, not a path" is asserted by the module that
94
+ * defines what a path is rather than only by a regex that has to imitate it.
95
+ * @param {*} fileName
96
+ * @returns {boolean}
97
+ */
98
+ function isSafeArtifactName(fileName) {
99
+ return typeof fileName === 'string'
100
+ && fileName === path.basename(fileName)
101
+ && ARTIFACT_NAME.test(fileName);
102
+ }
103
+
104
+ /**
105
+ * Read `zip` into memory EXACTLY ONCE, through ONE descriptor.
106
+ *
107
+ * THE PATH IS RESOLVED ONCE AND NEVER AGAIN. `fstatSync(fd)` — never
108
+ * `statSync(zip)` — so even the size we act on comes from the handle we opened;
109
+ * a symlink is already resolved, and a swap after this point cannot change the
110
+ * answer. Every read is POSITIONAL (`readSync(fd, buf, off, len, POSITION)`),
111
+ * so the shared file offset is never used and nothing else in this process can
112
+ * perturb it.
113
+ *
114
+ * THERE IS NO "COULD NOT ALLOCATE" BRANCH, and that is deliberate. Two council
115
+ * designs promised one; both were MEASURED wrong. `Buffer.allocUnsafe` does not
116
+ * throw when the machine is out of memory — the process dies, exactly as it
117
+ * does today when extract-zip inflates a 215 MiB entry. Writing a clean
118
+ * `{why:'no-memory'}` refusal and claiming it works would be a failure branch
119
+ * this change never executed. What DOES protect the allocation is `maxBytes`,
120
+ * checked against `fstat` before a byte is reserved.
121
+ *
122
+ * A TORN READ NEEDS NO SPECIAL HANDLING. If the attacker mutates the file while
123
+ * we are reading it, the buffer we assembled is what would have been extracted,
124
+ * its sha256 will not match the anchor, and the gate refuses. `grew` and
125
+ * `short-read` exist to name the shape, not to provide the security.
126
+ *
127
+ * @param {object} o
128
+ * @param {string} o.zip attacker-influenced path (a cache entry, or what
129
+ * `downloadArtifact` handed back)
130
+ * @param {number} [o.maxBytes] default MAX_ARTIFACT_BYTES
131
+ * @param {object} [o.fs]
132
+ * @returns {{bytes: Buffer, size: number}
133
+ * | {bytes: null, why: 'unreadable'|'not-a-file'|'empty'|'too-large'|'short-read'|'grew',
134
+ * detail: string}}
135
+ */
136
+ function readArtifactBytes({ zip, maxBytes = MAX_ARTIFACT_BYTES, fs = fsDefault }) {
137
+ let fd;
138
+ try {
139
+ fd = fs.openSync(zip, 'r');
140
+ } catch (e) {
141
+ return { bytes: null, why: 'unreadable', detail: (e && e.message) || String(e) };
142
+ }
143
+ try {
144
+ const st = fs.fstatSync(fd);
145
+ if (!st.isFile()) {
146
+ return { bytes: null, why: 'not-a-file', detail: 'it is not a regular file' };
147
+ }
148
+ if (st.size === 0) {
149
+ return { bytes: null, why: 'empty', detail: 'it is empty' };
150
+ }
151
+ if (st.size > maxBytes) {
152
+ return { bytes: null, why: 'too-large', detail: `${st.size} bytes exceeds the ${maxBytes}-byte ceiling` };
153
+ }
154
+ const bytes = Buffer.allocUnsafe(st.size);
155
+ let off = 0;
156
+ while (off < st.size) {
157
+ const n = fs.readSync(fd, bytes, off, Math.min(READ_CHUNK, st.size - off), off);
158
+ if (!(n > 0)) {
159
+ return { bytes: null, why: 'short-read', detail: `the file ended after ${off} of ${st.size} bytes` };
160
+ }
161
+ off += n;
162
+ }
163
+ // One positional read PAST the size we trusted. A file that grew under us is
164
+ // what an active swap looks like, and it means the bytes we hold are a prefix
165
+ // of something else — say that, rather than hashing a truncation.
166
+ const tail = Buffer.allocUnsafe(1);
167
+ if (fs.readSync(fd, tail, 0, 1, st.size) > 0) {
168
+ return { bytes: null, why: 'grew', detail: 'it changed size while amicus was reading it' };
169
+ }
170
+ return { bytes, size: st.size };
171
+ } catch (e) {
172
+ return { bytes: null, why: 'unreadable', detail: (e && e.message) || String(e) };
173
+ } finally {
174
+ try { fs.closeSync(fd); } catch { /* already closed */ }
175
+ }
176
+ }
177
+
178
+ module.exports = {
179
+ readArtifactBytes, isSafeArtifactName, MAX_ARTIFACT_BYTES, READ_CHUNK,
180
+ };
@@ -45,7 +45,7 @@ function _resetEnsureElectron() {
45
45
  * @param {object} [opts.deps] injected
46
46
  * { isElectronUsable, resolveElectronBinary, repairElectron, logProgress }.
47
47
  * @param {object} [opts.repairOptions] forwarded to repairElectron (electronDir, etc.).
48
- * @returns {Promise<{ok:boolean, path?:string, reason?:string}>}
48
+ * @returns {Promise<{ok:boolean, path?:string, reason?:string, unverified?:boolean}>}
49
49
  */
50
50
  function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
51
51
  const usable = deps.isElectronUsable || defaultIsUsable;
@@ -71,11 +71,32 @@ function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
71
71
  return { ok: false, reason: `Electron provisioning failed: ${err && err.message}` };
72
72
  }
73
73
  if (usable()) {
74
+ // A2/B3 (council, confirmed 4 of 4): `unverified` was WRITTEN on both
75
+ // provision routes and read by nothing in src/ or scripts/, while the docs
76
+ // said the outcome was "marked unverified". A flag no code and no human
77
+ // ever sees establishes no property at all. This is the launch-time
78
+ // reader; scripts/postinstall.js is the install-time one and
79
+ // doctor-electron-mcp-check.js reports it from `--fix`.
80
+ if (result && result.unverified) {
81
+ logProgress('[amicus] NOTE: this Electron binary is UNVERIFIED — either no published sha256 covered');
82
+ logProgress('[amicus] the artifact it came from, so its bytes were vouched for only by the');
83
+ logProgress('[amicus] mirror that served them, or its sha256 CONTRADICTED the published one and');
84
+ logProgress('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON accepted it anyway.');
85
+ }
74
86
  logProgress('[amicus] Electron GUI ready.');
75
- return { ok: true, path: resolve() };
87
+ return { ok: true, path: resolve(), ...(result && result.unverified ? { unverified: true } : {}) };
76
88
  }
77
- const reason = (result && result.reason)
78
- || `Electron could not be provisioned; the GUI is unavailable. ${HINTS.doctorFix} (or use --no-ui).`;
89
+ // THE POINTER IS NOT OPTIONAL. This used to be `result.reason || <the
90
+ // pointer>`, so the moment `repairElectron` started returning a reason for a
91
+ // failed controlled download (v4.9.6, when the last-resort installer was
92
+ // deleted and the failure became something to REPORT), the one line telling
93
+ // the user what to do next silently disappeared. A more detailed message is
94
+ // not a reason to stop giving advice.
95
+ const detail = (result && result.reason) || 'Electron could not be provisioned; the GUI is unavailable.';
96
+ // Matched on the COMMAND, not on the whole hint string: the AV-quarantine
97
+ // reason already ends with a bare `amicus doctor --fix` and must not be
98
+ // given a second, longer copy of the same advice.
99
+ const reason = detail.includes('doctor --fix') ? detail : `${detail} ${HINTS.doctorFix} (or use --no-ui).`;
79
100
  return { ok: false, reason };
80
101
  })().then((r) => {
81
102
  // Only memoize SUCCESS; a failure clears the guard so a later launch retries.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The ENV SCRUB — which environment names a hostile REPOSITORY can plant.
3
+ *
4
+ * WHAT THIS MODULE LOST, AND WHY (v4.9.6 second round, council seat B1).
5
+ * It used to build a scrubbed environment for the last-resort `install.js`
6
+ * SPAWN. That spawn is gone — it bypassed the custody and digest gate entirely
7
+ * — so `scrubbedChildEnv` and the `ELECTRON_INSTALL_TARGET_*` artifact selectors
8
+ * went with it: there is no child process left to hand an environment to. The
9
+ * measured enumeration they encoded is kept below as the record it is.
10
+ * `isRepoPlantedName` survives and is now used by the IN-PROCESS download scrub
11
+ * (electron-provision.js), which is the surface seat D5 filed against.
12
+ *
13
+ * SPLIT OUT of electron-trust.js (v4.9.6 F2). That module owns three things —
14
+ * the digest anchor, the gate, and this scrub — and sat at 299 of the repo's
15
+ * 300-line limit, so the F2 repair had nowhere to go. electron-trust.js
16
+ * RE-EXPORTS every name below, so existing imports keep working and the split is
17
+ * invisible to callers. This module is a LEAF (no requires at all); the arrow is
18
+ * electron-install -> electron-provision -> electron-trust -> electron-env-scrub
19
+ * and must never point back.
20
+ *
21
+ * The trust boundary it encodes: a hostile REPOSITORY (a clone the user opens,
22
+ * an unpacked sample) controls the `.npmrc` and `package.json` of the directory
23
+ * amicus's own docs tell people to run `npx -y amicus@latest` in. MEASURED (npm
24
+ * 11.16.0): that reaches the child as exactly two name shapes —
25
+ * `npm_config_<key lowercased>` and `npm_package_config_<key case-preserved>` —
26
+ * and NOTHING else. Bare `ELECTRON_*` and `AMICUS_*` names are out of its reach,
27
+ * which is why the escape hatch is a plain environment variable and why a
28
+ * machine-level `ELECTRON_MIRROR` is still honoured.
29
+ *
30
+ * @module sidecar/electron-env-scrub
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /**
36
+ * Env-name PREFIXES an untrusted REPOSITORY can plant for the mirror knobs.
37
+ *
38
+ * PREFIXES, not a hand-maintained name list. RE-ENUMERATED EXHAUSTIVELY for
39
+ * v4.9.6 F2 against `@electron/get` 5.0.0 (`dist/artifact-utils.js`, lines 20-35):
40
+ * `mirrorVar(name)` is called for exactly five names — `mirror`, `nightlyMirror`,
41
+ * `customDir`, `customFilename`, `customVersion` — and reads six spellings of
42
+ * each, thirty names in all:
43
+ * 1. `npm_config_electron_<name.toLowerCase()>` .npmrc
44
+ * 2. `NPM_CONFIG_ELECTRON_<SNAKE_UPPER>` .npmrc, npm's own casing
45
+ * 3. `npm_config_electron_<snake_lower>` .npmrc
46
+ * 4. `npm_package_config_electron_<name>` (case KEPT) package.json "config"
47
+ * 5. `npm_package_config_electron_<snake_lower>` package.json "config"
48
+ * 6. `ELECTRON_<SNAKE_UPPER>` plain env
49
+ * Rows 1-5 — twenty-five names — all begin with one of the two prefixes below
50
+ * under a case fold, so the prefixes cover every repo-reachable mirror knob
51
+ * exactly. Row 6 (`ELECTRON_MIRROR`, `ELECTRON_NIGHTLY_MIRROR`,
52
+ * `ELECTRON_CUSTOM_DIR`, `ELECTRON_CUSTOM_FILENAME`, `ELECTRON_CUSTOM_VERSION`)
53
+ * is BARE and therefore the machine owner's, and is deliberately kept.
54
+ *
55
+ * The prefixes also cover electron's own
56
+ * `npm_config_electron_use_remote_checksums` (install.js lines 47-50 — that name
57
+ * turns electron's bundled pin OFF), plus any knob a future @electron/get adds in
58
+ * the same namespace. Contrast ENGINE_CREDENTIAL_ENV
59
+ * (scripts/run-integration-keyless.js:101), whose own docblock warns that nothing
60
+ * makes a name list follow an upstream bump.
61
+ *
62
+ * The BARE `electron_use_remote_checksums` is deliberately NOT removed: a bare
63
+ * lower-case name is not repo-injectable, so it carries the machine owner's
64
+ * intent, exactly like a bare `ELECTRON_MIRROR`.
65
+ *
66
+ * MATCHED CASE-INSENSITIVELY. This used to fold no case, on the claim that
67
+ * because the Windows environment block is case-insensitive, deleting the
68
+ * lower-case name also removed the `NPM_CONFIG_ELECTRON_*` view @electron/get
69
+ * reads second. That is true of `process.env` and FALSE of the `{...env}` PLAIN
70
+ * OBJECT this module actually deletes from — a plain object is case-sensitive on
71
+ * every platform, so the upper-case key survived and was handed to the child.
72
+ * RE-MEASURED (npm 11.16.0, Windows 11) — two ways a repository reaches an
73
+ * upper-case slot:
74
+ * 1. `.npmrc` `electron_mirror=…` while `NPM_CONFIG_ELECTRON_MIRROR` already
75
+ * exists in the environment: npm overwrites that slot's VALUE and never
76
+ * renames it, so the child sees the ATTACKER's URL under the upper-case name.
77
+ * 2. `package.json` `"config": {"ELECTRON_MIRROR": …}`: npm PRESERVES the key's
78
+ * case, planting `npm_package_config_ELECTRON_MIRROR` with nothing
79
+ * pre-existing at all — and @electron/get's own lookup for
80
+ * `npm_package_config_electron_mirror` (row 5 above) finds it, because the
81
+ * Windows lookup is case-insensitive too.
82
+ * The old docblock's POSIX half (`NPM_CONFIG_ELECTRON_*` is a distinct variable
83
+ * npm never writes there, so it is the machine owner's) is NOT measurable from
84
+ * this machine, and it is load-bearing in the fail-OPEN direction: wrong, it
85
+ * hands the child an attacker's mirror. Wrong the other way it costs one
86
+ * alternate spelling inside a last-resort spawn, while bare `ELECTRON_MIRROR`
87
+ * — which @electron/get ranks FIRST — still carries owner intent. So the fold is
88
+ * unconditional rather than resting on an unverified platform claim.
89
+ */
90
+ const REPO_ENV_PREFIXES = ['npm_config_electron_', 'npm_package_config_electron_'];
91
+
92
+ /**
93
+ * THE ARTIFACT SELECTORS ARE GONE WITH THE SPAWN, and this is the record of what
94
+ * they were, because it was measured and a later change may need it.
95
+ *
96
+ * `ELECTRON_INSTALL_TARGET_ENV` held `npm_config_platform` / `npm_config_arch`
97
+ * and their `npm_package_config_*` spellings (council B2 added the second pair).
98
+ * They mattered because they chose WHICH artifact `install.js` fetched. Nothing
99
+ * spawns install.js any more (seat B1), and amicus's own downloader is passed
100
+ * `platform` and `arch` as ARGUMENTS, so no environment name can choose them.
101
+ *
102
+ * RE-ENUMERATED EXHAUSTIVELY, before the deletion, against the installed
103
+ * `node_modules/electron` (43.1.1) `install.js`. Every `process.env` read there:
104
+ * ELECTRON_INSTALL_PLATFORM (20, 99) bare — amicus used to SET it
105
+ * npm_config_platform (20, 99) REPO-PLANTABLE
106
+ * ELECTRON_INSTALL_ARCH (21) bare — amicus used to SET it
107
+ * npm_config_arch (21, 27) REPO-PLANTABLE
108
+ * force_no_cache (45) bare
109
+ * electron_config_cache (46) bare — the machine owner's cache root
110
+ * electron_use_remote_checksums (48) bare — the owner's
111
+ * npm_config_electron_use_remote_checksums (48) covered by the prefixes above
112
+ * ELECTRON_OVERRIDE_DIST_PATH (73, 80) bare
113
+ * install.js 43.1.1 did NOT itself read `npm_package_config_platform` / `-arch`.
114
+ *
115
+ * WHAT THE IN-PROCESS SCRUB NEEDS is only the prefixes: `@electron/get` 5.0.0
116
+ * reads `mirror`, `nightlyMirror`, `customDir`, `customFilename` and
117
+ * `customVersion` under `npm_config_electron_*` / `npm_package_config_electron_*`
118
+ * (dist/artifact-utils.js, lines 20-35) and takes platform and arch as call
119
+ * arguments. So `isRepoPlantedName` covers the download surface exactly.
120
+ */
121
+
122
+ /** True for a name a hostile repository could have planted, in ANY case (see above). */
123
+ function isRepoPlantedName(name) {
124
+ return REPO_ENV_PREFIXES.some((prefix) => String(name).toLowerCase().startsWith(prefix));
125
+ }
126
+
127
+ /**
128
+ * Run `fn` with every repo-plantable electron name DELETED from `env`, restoring
129
+ * each one before returning — whether `fn` returned a value, returned a promise,
130
+ * or threw (council seat D5).
131
+ *
132
+ * THE HOLE THIS CLOSES. The v4.9.6 mirror-knob scrub covered only the
133
+ * last-resort `install.js` SPAWN, and amicus's own controlled download runs
134
+ * `@electron/get` IN THIS PROCESS, reading `process.env` directly. So a hostile
135
+ * repository could still point amicus's own download at its mirror. It was filed
136
+ * as a nit because the digest pin refuses the redirected bytes anyway — this is
137
+ * a wasted download, not a compromise — but a stated threat model that is wider
138
+ * than the code is its own defect.
139
+ *
140
+ * ── WHAT IT COVERS, MEASURED RATHER THAN REASONED (round 3, seat B1) ───────
141
+ * The previous version of this docblock ARGUED, from a source read, that every
142
+ * repo-plantable name is read in `downloadArtifact`'s synchronous prefix. Seat
143
+ * B1 called that "an unverified invariant about @electron/get internals" and
144
+ * was right to: the argument had never been run, and the scrub pattern was
145
+ * copied from `utils/engine-output-flag.js :: withOutputTokenFlag`, where it is
146
+ * correct only because the thing it guards reads the env synchronously at spawn.
147
+ *
148
+ * So it was MEASURED, against the INSTALLED @electron/get 5.0.0 (Node 24.18.0,
149
+ * Windows 11), by replacing `process.env` with a recording Proxy and driving a
150
+ * real `downloadArtifact` with an injected offline downloader:
151
+ *
152
+ * PINNED call (amicus's normal route — a `checksums` table goes out)
153
+ * 20 repo-plantable reads, ALL INSIDE the scrub window; 0 after the restore.
154
+ * Four knobs on a stable version (`customVersion` from `getArtifactVersion`,
155
+ * then `mirror`, `customDir`, `customFilename` from `getArtifactRemoteURL`)
156
+ * x five repo-reachable spellings; `nightlyMirror` adds five on a nightly.
157
+ * POSITIVE CONTROL, `npm_config_electron_mirror=https://ATTACKER.example/mirror/`:
158
+ * scrubbed -> https://github.com/electron/electron/releases/download/v43.1.1/…
159
+ * unscrubbed -> https://ATTACKER.example/mirror/ATTACKERDIR/ATTACKER.zip
160
+ * The scrub is therefore NOT a no-op: it is what puts that download back on
161
+ * the official URL.
162
+ *
163
+ * UNPINNED call (no anchor, or the hatch dropped the pin — no `checksums`)
164
+ * The same 20 land inside the window and the ARTIFACT still comes from the
165
+ * official URL. Then `validateArtifact` recursively `downloadArtifact`s
166
+ * `SHASUMS256.txt` AFTER awaits, with the environment restored, and reads
167
+ * the planted names back — 13, not 20, because `mirrorVar`'s `||` chain
168
+ * short-circuits as soon as a planted name answers.
169
+ *
170
+ * THE RESIDUAL IS AVAILABILITY-ONLY, and that is why it is documented rather
171
+ * than closed. The zip's URL was already fixed inside the scrub, so a planted
172
+ * mirror cannot substitute the bytes — it can only serve a checksum file that
173
+ * disagrees with the official artifact, which FAILS the download.
174
+ *
175
+ * IT IS STATED IN BOTH PLACES A USER READS IT, which took two rounds. Round 3
176
+ * narrowed `docs/configuration.md` and left the SAME overclaim standing at
177
+ * `docs/troubleshooting.md`, on the very bullet that tells a user to set
178
+ * `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` — i.e. on the page a user in exactly the
179
+ * UNPINNED configuration is sent to. Round 4 corrected it there too. When a
180
+ * claim about this module is narrowed, grep the docs for the phrasing rather
181
+ * than the file that suggested it.
182
+ *
183
+ * ── NOTHING OUTSIDE CAN OBSERVE IT (seat B3, REFUTED BY MEASUREMENT) ───────
184
+ * B3 read this as mutating shared `process.env` "while the download is still in
185
+ * flight", so "concurrent repairs can interleave". They cannot: delete -> call
186
+ * -> restore contains no `await`, so it is ONE synchronous turn and no other
187
+ * task can be scheduled inside it. MEASURED: two concurrent scrubbed downloads
188
+ * with an outside observer sampling `process.env` from the microtask, immediate
189
+ * and timer queues — 11380 samples, 0 saw a scrubbed environment, and both
190
+ * resolved to the official URL. The await-free window IS the control, which is
191
+ * why `fn` is called synchronously and its promise is returned UNAWAITED.
192
+ * `tests/electron-env-scrub-get5-contract.test.js` re-measures every number
193
+ * above against the installed library on each run, so a @electron/get that moves
194
+ * a read past an await turns red there instead of in the field.
195
+ *
196
+ * ── THREE REMEDIES CONSIDERED AND REJECTED, each with its reason ───────────
197
+ * 1. Pass the values instead of scrubbing. IMPOSSIBLE: `mirrorVar` ranks
198
+ * `process.env` ABOVE `options[name]` (dist/artifact-utils.js, lines 20-35),
199
+ * so a planted name beats anything amicus puts in `mirrorOptions`.
200
+ * 2. `mirrorOptions.resolveAssetURL`, which does override the URL outright and
201
+ * IS inherited by the recursive SHASUMS256 call. Rejected: it bypasses
202
+ * `base` entirely, so a machine owner's bare `ELECTRON_MIRROR` — which this
203
+ * module deliberately keeps — would silently stop being honoured, and amicus
204
+ * would have to hand-build electron release URLs.
205
+ * 3. Hold the scrub for the whole call. Rejected by the finding itself: that is
206
+ * the shared-mutation-across-an-await B3 filed, and it would hand a scrubbed
207
+ * environment to every unrelated child a long-lived MCP process spawns in
208
+ * that window.
209
+ *
210
+ * `delete` and restore, not `= undefined`: assigning undefined to a process.env
211
+ * key stores the STRING 'undefined', which `mirrorVar` would read as a truthy
212
+ * mirror URL and use.
213
+ * @template T
214
+ * @param {() => T} fn called synchronously, exactly once
215
+ * @param {NodeJS.ProcessEnv} [env] defaults to process.env
216
+ * @returns {T} whatever fn returned (a promise is returned, never awaited here)
217
+ */
218
+ function withScrubbedRepoEnv(fn, env = process.env) {
219
+ const removed = [];
220
+ for (const name of Object.keys(env)) {
221
+ if (isRepoPlantedName(name)) {
222
+ removed.push([name, env[name]]);
223
+ delete env[name];
224
+ }
225
+ }
226
+ try {
227
+ return fn();
228
+ } finally {
229
+ for (const [name, value] of removed) { env[name] = value; }
230
+ }
231
+ }
232
+
233
+ module.exports = { isRepoPlantedName, withScrubbedRepoEnv, REPO_ENV_PREFIXES };