amicus 4.9.5 → 4.9.7

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.
@@ -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 };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * WHICH exe a package resolves through, and whether a `dist/` HOLDS one.
3
+ *
4
+ * ONE RULE, ONE HOME. `resolveElectronBinary` (electron-install.js) decides what
5
+ * amicus will SPAWN; `promoteDist`'s retirement guard (electron-layout.js)
6
+ * decides what amicus may DELETE. Until v4.9.7 only the first one read
7
+ * `path.txt` — the second asked whether `dist/` held THIS HOST'S default exe —
8
+ * and a package cross-installed through `npm_config_platform` holds a different
9
+ * basename, so a failed promote destroyed a working tree (A1). Two copies of a
10
+ * rule are free to drift; this module exists so there is one.
11
+ *
12
+ * WHICH VALUE `promoteDist`'S GUARD MAY READ, since getting this wrong is how
13
+ * the fix would have been as blind as the defect:
14
+ * - `raw`, the bytes `path.txt` held BEFORE the promote's step 0 — YES.
15
+ * - `replaced` (electron-layout.js) — NO. It is nulled in exactly the branch
16
+ * the guard most needs a name for, and it is untrimmed, so feeding it to
17
+ * `path.join` fails OPEN on a trailing newline.
18
+ * - A RE-READ of `path.txt` at the guard — NO, and this is the sharp one.
19
+ * Step 0 has already written `platformExe` there, so a re-reading guard
20
+ * reads its own writer's value and learns nothing. MEASURED: at the guard
21
+ * the file says `electron.exe` even for a package cross-installed as
22
+ * `electron`, and the tree is deleted exactly as before the fix. That is
23
+ * failure mode #21, the echoed read-back.
24
+ *
25
+ * `ELECTRON_OVERRIDE_DIST_PATH` IS DELIBERATELY NOT PART OF THIS RULE, and
26
+ * `promoteDist` gains no `env`. The guard governs a DELETE of `distDir` and
27
+ * nothing else, so the only question is what THAT tree holds; under an override
28
+ * `resolveElectronBinary` does not look in `dist/` at all. Ignoring it can only
29
+ * make the guard readier to find an exe — the fail-CLOSED direction.
30
+ *
31
+ * TRUE LEAF: `path` only, with `fs` injected by the caller — so both callers can
32
+ * require it with no risk of a cycle.
33
+ *
34
+ * @module sidecar/electron-exe-rel
35
+ */
36
+
37
+ 'use strict';
38
+
39
+ const path = require('path');
40
+
41
+ /** Platform exe basename, matching electron's getPlatformPath(). */
42
+ function platformExe(platform) {
43
+ switch (platform) {
44
+ case 'mas':
45
+ case 'darwin':
46
+ return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
47
+ case 'win32':
48
+ return 'electron.exe';
49
+ default:
50
+ return 'electron';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The relative exe path a package RESOLVES through, from `path.txt`'s RAW bytes.
56
+ *
57
+ * `resolveElectronBinary`'s rule, stated once: TRIM, and fall back to
58
+ * `platformExe` when the file is absent, unreadable OR blank. `null` is the
59
+ * caller's "the read threw".
60
+ *
61
+ * THE TRIM AND THE BLANK ARM ARE BOTH LOAD-BEARING, and the A1 filing named
62
+ * neither — it said "absent or unreadable". MEASURED: a guard that skips the
63
+ * trim deletes a real `dist/electron.exe` under a `path.txt` of
64
+ * `"electron.exe\n"`, because `existsSync(join(dist, 'electron.exe\n'))` is
65
+ * false on Windows; and one that returns a blank value names `dist/` ITSELF,
66
+ * which exists, so it would refuse every promote forever.
67
+ */
68
+ function heldExeRel(raw, platform) {
69
+ const rel = typeof raw === 'string' ? raw.trim() : '';
70
+ return rel || platformExe(platform);
71
+ }
72
+
73
+ /**
74
+ * WHICH executable `distDir` holds — under either name it could resolve
75
+ * through — or `null` for a tree that is not an install under any of them.
76
+ *
77
+ * A UNION, NEVER A REPLACEMENT, and the union is why this returns a NAME. The
78
+ * filing's literal rule ("judge by what `path.txt` names") was MEASURED to open
79
+ * three new holes it does not mention: a whitespace-only `path.txt`, one with a
80
+ * trailing newline, and a TRUNCATED one (`electr` — the shape `promoteDist`'s
81
+ * own best-effort put-back can leave) each turned a real `dist/electron.exe`
82
+ * into "not an install, delete it". So `platformExe` is not replaced by the
83
+ * `path.txt` name; it is joined by it, and the set of trees this licenses
84
+ * deleting can only ever SHRINK.
85
+ *
86
+ * ARM 1 IS THE PRE-FIX RULE, BYTE FOR BYTE, and it runs first and
87
+ * unconditionally. That ordering is the guarantee: no tree the shipped guard
88
+ * protects today can be deleted by this one.
89
+ *
90
+ * ARM 2 CARRIES TWO BOUNDS THE FIRST DOES NOT NEED.
91
+ * CONTAINED — a `path.txt` of `..`, `.`, `''` or `../SIBLING` joins to
92
+ * something that EXISTS outside `dist/` (all MEASURED true), which would
93
+ * refuse every promote forever while claiming `dist/` held an exe it never
94
+ * held. The predicate is `zip-entry-write.js :: writeSymlink`'s, verbatim —
95
+ * including the `path.sep`, whose absence MEASURABLY fails OPEN: a legal
96
+ * `dist/..electron.exe` reads as escaping and the tree is deleted.
97
+ * A FILE, NOT A DIRECTORY — every natural truncation of the darwin name
98
+ * (`Electron.app`, `Electron.app/Contents`, `Electron.app/Contents/MacOS`) is
99
+ * a real DIRECTORY in a real tree, and `existsSync` says true for all three.
100
+ * Accepting one would wedge the self-heal permanently on the AV-quarantine
101
+ * shape it exists for, printing "dist/ holds a usable Electron.app".
102
+ *
103
+ * A throwing `existsSync` (only an injected fs does this) reads as "I could not
104
+ * establish that this tree is empty", which refuses. Fail closed.
105
+ *
106
+ * @param {object} o
107
+ * @param {string} o.distDir
108
+ * @param {string|null} o.raw path.txt's bytes BEFORE any writer touched them
109
+ * @param {string} o.platform
110
+ * @param {object} o.fs
111
+ * @returns {string|null} the exe path, relative to `distDir`, that was found
112
+ */
113
+ function distHeldExe({ distDir, raw, platform, fs }) {
114
+ const fallback = platformExe(platform);
115
+ // ARM 1 — the pre-fix rule, unchanged and first.
116
+ try { if (fs.existsSync(path.join(distDir, fallback))) { return fallback; } } catch { return fallback; }
117
+ const held = heldExeRel(raw, platform);
118
+ if (held === fallback) { return null; }
119
+ // ARM 2 — the name path.txt gives, contained and required to be a file.
120
+ const full = path.join(distDir, held);
121
+ const inside = path.relative(distDir, full);
122
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { return null; }
123
+ try { return fs.statSync(full).isFile() ? held : null; } catch { return null; }
124
+ }
125
+
126
+ /** Write path.txt: the basename `electron/index.js` joins onto `dist/`. */
127
+ function writePathTxt({ electronDir, platform, fs }) {
128
+ fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
129
+ }
130
+
131
+ module.exports = { platformExe, writePathTxt, heldExeRel, distHeldExe };