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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.9.5",
3
+ "version": "4.9.7",
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,244 @@
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.7] - 2026-09-09
7
+
8
+ Three findings deferred from the v4.9.6 cut, and the two open questions filed beside them. The
9
+ council run that raised the three reviewed at **2 of 4 seats**, so each carried the weight of two
10
+ seats — and on the one that mattered most, the filing turned out to be right about the defect and
11
+ wrong about the fix.
12
+
13
+ ### Fixed
14
+
15
+ - **A promote can no longer delete a working install that was cross-installed for another
16
+ platform.** v4.9.6 captured and restored a `path.txt` naming another platform's executable, but
17
+ the guard that decides whether a `dist/` may be removed in place still asked only about THIS
18
+ platform's default name. On a package installed through `npm_config_platform`, a promote whose
19
+ retirement rename failed therefore restored the pointer and deleted the tree it pointed at —
20
+ reproduced against the shipped code with a real filesystem and only `renameSync` injected to
21
+ throw the EPERM the fallback exists for.
22
+
23
+ The guard now judges by what `path.txt` actually names. **As a union with the platform default,
24
+ never as a replacement** — which is the part the finding did not say, and implementing its
25
+ sentence literally destroys a working `dist/electron.exe` on three shapes the old guard protected:
26
+ a whitespace-only `path.txt`, one with a trailing newline (`existsSync` of a name ending in `\n`
27
+ is false on Windows), and a TRUNCATED one — `electr`, the shape this function's own best-effort
28
+ put-back can leave. The platform-default arm runs first and is unchanged, so the set of trees a
29
+ promote will delete can only ever shrink.
30
+
31
+ The `path.txt` name additionally has to be **contained** in `dist/` and has to be a **file**: a
32
+ `path.txt` of `..`, `.` or `../SIBLING` names something that exists but that the delete never
33
+ touches, and every truncation of the darwin name (`Electron.app`, `Electron.app/Contents`,
34
+ `.../MacOS`) is a real directory — accepting either would refuse every promote forever while
35
+ reporting that `dist/` "holds a usable Electron.app". The refusal message now names the executable
36
+ that was found rather than the one that was looked for, because `docs/troubleshooting.md` tells
37
+ the user that refusal means the tree holds a usable executable.
38
+
39
+ An **unreadable** `path.txt` now refuses rather than guessing; an absent one still heals. The rule
40
+ itself moved to `src/sidecar/electron-exe-rel.js` and `resolveElectronBinary` now calls it, with a
41
+ table-driven test asserting both sides agree over seven `path.txt` shapes on three platforms with
42
+ and without `ELECTRON_OVERRIDE_DIST_PATH`. Two copies of one rule are what let these drift, and
43
+ the finding's own words for the defect were "did not carry one function over".
44
+
45
+ - **(docs)** `.github/workflows/ci.yml` claimed `--omit=optional` "skips the Electron download". It
46
+ skips the optional *package*; there is no download on the default path to skip, because
47
+ `electron@43.1.1` ships no install script at all. Measured on run `34246117877` across all three
48
+ runner platforms.
49
+
50
+ ### Security
51
+
52
+ - **The rescue boundary now reads BOTH tables an archive declares its entry names in.** A zip
53
+ carries its names twice — in the central directory and in each local file header — and amicus
54
+ read only the first. An archive can blind that one while leaving every local header whole: cutting
55
+ the tail off does it by accident, and four one-field edits to a *complete* end-of-central-directory
56
+ record do it on purpose. Measured against the real extractor with the hatch armed, **seven such
57
+ archives carrying a `../../../` entry reached a native extractor, and two ran to completion and
58
+ promoted the result.** Nothing escaped only because the Windows tools refuse `..` themselves — the
59
+ exact reliance this subsystem says amicus will not make.
60
+
61
+ The two tables can also disagree, and the tools do not agree on which to believe: measured,
62
+ `tar.exe` wrote the name from the local header while `Expand-Archive` wrote the one from the
63
+ central directory. A refusal in either table now refuses the archive.
64
+
65
+ **It costs no measured availability.** On six real Electron artifacts the local walk enumerates
66
+ every name in 0-1 ms and agrees with the central table entry-for-entry; on a truncated archive,
67
+ where the central walk goes blind, it still reads them all. The archives this rescue exists for
68
+ are still rescued. And the notice printed before a spawn now says *which* names were checked —
69
+ all of them, some of them, or none — because a real artifact truncated by a few kilobytes leaves
70
+ both walks incomplete while every name it found was read and cleared, and a two-state notice would
71
+ have claimed nothing was checked over dozens that were.
72
+
73
+ **Two council rounds then found the first cut of this incomplete, and the second cut of it
74
+ incomplete again — both in the same direction.** A walk that could not follow the chain used to
75
+ STOP, and an entry it never reached is an entry it cannot refuse: a lying size jumped one, and
76
+ later a *standard* data-descriptor entry (general-purpose bit 3 with a zero size — an ordinary
77
+ streaming encoding, not a malformed one) hid everything behind it. Neither needed an exotic
78
+ archive. So the walk now DEMOTES its claim and keeps going wherever it still has an offset to
79
+ follow — because a name it can still read is a name it can still refuse — and where it genuinely
80
+ has none, it SWEEPS the region it could not reach for local headers and puts their names through
81
+ the same rule. It can only add refusals, so an archive that is rescued today stops being rescued
82
+ only if it declares a hostile name. **The practical consequence for a user: an archive carrying a
83
+ traversal name behind an unreadable point is now refused where it previously reached the native
84
+ extractor.**
85
+
86
+ The claim "every name was checked" also became a conjunction rather than a disjunction. The two
87
+ tables carry different names and the two extractors read different ones, so one readable table
88
+ never meant both were clean — measured, a stopped local walk beside a benign central directory
89
+ reported all-clear and printed nothing while `tar.exe` reached a `../../../` entry only the local
90
+ table carried.
91
+
92
+ ### Added
93
+
94
+ - **A macOS job that runs the real extract path over the real Electron `.app` artifact**
95
+ (`.github/workflows/darwin-bundle.yml`, `scripts/probe-darwin-extract.js`). v4.9.6 shipped a
96
+ symlink target-escape check that `extract-zip` does not have, and disclosed that it might *reject*
97
+ a layout that previously worked. **It does not** — measured against the real
98
+ `electron-v43.1.1-darwin-arm64.zip`: 585 records, 14 symlinks, every target relative, none with a
99
+ `..` component, none absolute, and none of the 585 entry names traversing a symlinked component.
100
+ The linux artifacts hold zero symlink entries, so that path is unreachable there at all.
101
+
102
+ What was left was coverage rather than risk, and the job closes it: a real `symlinkSync`, a real
103
+ `realpathSync` behind the chain control, the absolute-target branch on POSIX arithmetic, and
104
+ `Electron --version` loading the framework through two of the fourteen links. Ten assertions, each
105
+ naming the one-line mutation it catches. The parity diff against `@electron-internal/extract-zip`'s
106
+ own tree ships **report-only** on its first cut — that comparison has never been measured, and a
107
+ gate that has never been run once is not evidence.
108
+
109
+ ### Changed
110
+
111
+ - **The claim that the native extractors refuse traversal themselves is now re-measured on every CI
112
+ run, instead of being asserted once.** That claim is the compensating control the rescue's
113
+ boundary leans on, and a claim nothing re-measures is a claim that rots.
114
+ `tests/sidecar/native-extractor-containment.test.js` drives 12 escape shapes through every
115
+ strategy that resolves on the machine. Newly measured this way: Info-ZIP `unzip` contains its own
116
+ escapes by stripping them, and GNU `tar` cannot read a zip at all — both were previously written
117
+ down as unmeasured. **`ditto`, the first macOS strategy, is now the only unmeasured one**, and the
118
+ macOS runner already in the matrix measures it the first time this suite runs there.
119
+
120
+ A proposed runtime "fence" around the Electron package directory was **refused rather than
121
+ deferred**, and the reasons are recorded because they were measured: a names-diff fence is blind
122
+ to an overwrite of `dist/electron.exe` or `path.txt`; and signalling a breach through the
123
+ available channel deletes the user's cached artifact and reports it corrupt — which a *false*
124
+ breach, reachable from a concurrent provision, would then do to the air-gapped machine this whole
125
+ feature exists for.
126
+
127
+ Half of the finding that prompted it was already false: everything the rescue writes lives under a
128
+ private incoming directory that is deleted unconditionally, so only an absolute write outside that
129
+ tree survives a failed strategy. Two shipped documents said otherwise and are corrected. The child
130
+ now also runs with that directory as its working directory — measured neutral, and it costs a line.
131
+
132
+ - **The CI council bench reserves 64,000 output tokens per leg instead of the engine's 32,000
133
+ default** — for every PR whose base carries the change. The alias map is fetched from the BASE
134
+ REF, so a branch cannot change the reviewers of its own PR; the config therefore lands on `main`
135
+ separately and takes effect for runs opened after it, never for the branch carrying it. Measured:
136
+ the run on the PR that first carried this reported `outputBudget is unset` and lost a seat at the
137
+ 32,000 default exactly as before. Across the five paid council runs on the v4.9.6 branch the bench averaged **2.8 of 4
138
+ seats**, and four of the six lost seats died at the reservation with `finish: 'length'` and
139
+ 0–651 usable output tokens.
140
+
141
+ **64,000 is measured, not chosen.** Against the pinned engine with the bare descriptors CI
142
+ registers, it lands unclamped on all four seats and the chair in both catalogue states. The
143
+ binding ceiling is **65,536**, and on a cold read it is shared by **two** rows — the chair and
144
+ `deepseek-v4-flash-0731` — not by the chair alone: the flag at 100,000 arrives as
145
+ `max_tokens 65536` on both. A budget above it would land in full on three seats while two were
146
+ silently clamped, and a run would stop reserving one number. The pre-flight step now fails before
147
+ any spend on a budget above the ceiling or on one amicus would reject and silently replace with
148
+ 32,000; the alias map's own validator inspects aliases only, so nothing caught that before.
149
+
150
+ **What it does not fix, said up front:** the other two lost seats — and ten of the eleven failed
151
+ first attempts — produced nothing at all and never reached a reservation. Expect roughly 3.6 of 4,
152
+ not 4 of 4. Read the death class rather than the seat count: three of the four targeted legs ran
153
+ 529–596 s of a 960 s leg cap, so an `OUTPUT_LENGTH` death can convert into a generic timeout,
154
+ which is the same lost seat with a worse diagnosis. The leg cap deliberately did not move in the
155
+ same change — raising it busts the 75-minute job cap, and a bust cancels the job, which deletes
156
+ the run-directory artifact every diagnosis of this class rests on.
157
+
158
+ ## [4.9.6] - 2026-09-08
159
+
160
+ *Amicus never itself writes, or reports as verified, bytes it did not hash.*
161
+
162
+ v4.9.5 hashed the Electron artifact **at a path** and then handed **that path** to an extractor,
163
+ which re-opened it. Anyone able to write the Electron cache directory — running as the same user —
164
+ could substitute the bytes in between, and the swapped archive was extracted and launched. Three
165
+ remedies were tried and two were defeated by measurement before one held; the failures are recorded
166
+ below because they are the useful part.
167
+
168
+ ### Security
169
+
170
+ - **The artifact is read once, into memory, and never re-opened (#237).** One `open`, `fstat` on the
171
+ descriptor rather than `stat` on the name, positional reads into a single Buffer, sha256 over
172
+ **that Buffer**, extraction from **that Buffer**. There is no second path resolution left to race:
173
+ instrumenting every path-taking call across a full repair records exactly one `openSync` of the
174
+ artifact, and poisoning the file on disk afterwards cannot alter the hashed bytes. Both the cached
175
+ and the downloaded routes go through it.
176
+
177
+ **What was tried first, and why it failed** — because "we staged it privately" is the intuition
178
+ this release exists to correct:
179
+ - *Rename into a private directory.* A rename moves a directory **entry**, not an inode. An
180
+ attacker who hard-links the cache entry keeps a name for the same bytes and writes through it
181
+ after the rename.
182
+ - *Copy into a `0700` directory under the system temp.* `0700` excludes **other** users, not the
183
+ one the threat model actually assumes. Worse in practice: the directory prefix was fixed and
184
+ discoverable on a first `readdir`, and on Windows the `chmod` was skipped entirely, so the mode
185
+ was never even attempted.
186
+ - *A retained file descriptor.* Not custody either: a same-user `writeFileSync` truncates and
187
+ rewrites the **same inode**, and the held descriptor then reads the substituted bytes.
188
+
189
+ - **The last-resort Electron installer is deleted.** It performed its own download and extraction,
190
+ bypassing every control this release adds; it pinned checksums out of the **scanned** directory,
191
+ reopening the anchor hole v4.9.5 closed; and it extracted with no stall bound at all. Its one
192
+ claimed justification — that amicus's dependency tree might fail to resolve `@electron/get` where
193
+ Electron's own tree succeeds — was measured false from both resolution paths.
194
+
195
+ - **A native-extractor rescue exists, but only behind `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`.** Every
196
+ OS extractor takes a **path**, so using one means writing bytes down and letting a child process
197
+ open them — the exact custody the rest of this release establishes. That trade is available only
198
+ under a flag whose documented meaning is already "I accept Electron bytes amicus cannot vouch
199
+ for", it is announced on stderr **before** the child is spawned in the terms above rather than as
200
+ a safe operation, and its result is always marked `unverified` even when the artifact's own sha256
201
+ matched. It fires on one classified failure — the extractor's positive "this archive is bad"
202
+ verdict — and on nothing else: a path-traversal refusal stays terminal and does not even mention
203
+ the flag, because inviting a retry would be laundering a security refusal through a human.
204
+
205
+ - **`yauzl` is now a declared dependency.** It previously resolved only through `extract-zip`, which
206
+ is the shape of the v4.5.2 outage this project already recorded.
207
+
208
+ - **The repo-plantable Electron mirror names are scrubbed around amicus's own in-process download**,
209
+ not only the (now removed) child spawn. Measured against the installed library rather than
210
+ assumed: on the pinned route all twenty reads land inside the scrub window and none after. A
211
+ contract test re-runs that measurement on every suite run, so a library that moves a read past an
212
+ `await` fails here rather than in the field.
213
+
214
+ ### Fixed
215
+
216
+ - **A promote can no longer cost you a working install.** `path.txt` is written **first**, while
217
+ `dist/` is still whole — its value never depended on the new tree — and a failure there refuses
218
+ the promote instead of leaving a replacement Electron the npm entry point cannot resolve. A
219
+ `path.txt` naming another platform's executable (an `npm_config_platform` cross-install) is
220
+ captured and restored on every failure exit.
221
+ - **The in-memory extraction is bounded and actually stops.** The idle bound arms on **bytes the
222
+ destination accepted**, so a single large file on slow storage is not mistaken for a stall, and
223
+ firing it aborts the pipeline rather than merely reporting a failure while the work continues.
224
+ - **Extraction litter is swept.** Incoming and retired trees from an interrupted promote no longer
225
+ accrete in the Electron package.
226
+
227
+ ### Known limits, stated rather than implied
228
+
229
+ - **The symlink handling is unverified on macOS and Linux.** The darwin artifact is an `.app` bundle
230
+ containing real symlinks, and the target-escape check added here is a behaviour `extract-zip` does
231
+ not have — it could reject a layout that previously worked. It could not be exercised on the
232
+ machine this was built on.
233
+ - Peak memory during a repair rises to roughly 210–260 MB, floored at the artifact size.
234
+ - Unchanged from v4.9.5: `registry=` in a hostile `.npmrc` dominates every control here and amicus
235
+ cannot close it; Electron's own npm postinstall runs before any amicus code; nothing verifies the
236
+ binary at launch — this closes acquisition, not custody of what is already installed; and where no
237
+ published digest covers an artifact at all, those bytes are extracted and marked `unverified`
238
+ rather than refused.
239
+ - Three findings from the final review are deferred to 4.9.7 and filed in `BACKLOG.md`: a failed
240
+ `dist` retirement can still delete a working install whose `path.txt` names another platform's
241
+ executable; a truncated archive whose entry names cannot be read reaches the native rescue; and
242
+ the rescue's cleanup does not reach writes a native tool makes outside its own directory.
243
+
6
244
  ## [4.9.5] - 2026-09-07
7
245
 
8
246
  *A repository you cloned could choose which bytes became your Electron.*
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.5",
458
+ "version": "4.9.7",
459
459
  "model": "google/gemini-2.5-flash",
460
460
  "phase": "terminal"
461
461
  }
package/docs/ROADMAP.md CHANGED
@@ -13,13 +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.5** (2026-09-07). Each 4.x rev below leads with the benefit, not the
16
+ Amicus is at **v4.9.7** (2026-09-09). Each 4.x rev below leads with the benefit, not the
17
17
  plumbing; the patch releases since v4.9.0 carry no section of their own, because each corrected a
18
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
+ `output-budget` doctor row, v4.9.5's Electron digest gate, v4.9.6's artifact custody, v4.9.7's dual name-table rescue boundary) it did so to
20
+ make an existing promise true, not to widen it. See `CHANGELOG.md` for what each one contained.
21
21
 
22
- **Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.5 patch releases —
22
+ **Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.7 patch releases —
23
23
  everything on this page is a record of what landed, not a plan. Composition — the scope that
24
24
  carried the number v4.6 here until the degrade-announcement-invariant milestone took the v4.6.0
25
25
  release (2026-08-02) — is now an unscheduled candidate for the next rev, tabled in its own section
@@ -109,13 +109,22 @@ src/
109
109
  │ ├── conversation-mirror.js
110
110
  │ ├── crash-handler.js # Crash Handler - Updates metadata to 'error' on uncaught exceptions
111
111
  │ ├── electron-cache.js # Electron download-cache root resolution (#53 helper).
112
+ │ ├── electron-custody.js # CUSTODY of the Electron artifact: one open, one read, one Buffer.
112
113
  │ ├── electron-ensure.js # ensureElectron() — lazy first-GUI provisioning (#55).
114
+ │ ├── electron-env-scrub.js # The ENV SCRUB — which environment names a hostile REPOSITORY can plant.
115
+ │ ├── electron-exe-rel.js # WHICH exe a package resolves through, and whether a `dist/` HOLDS one.
113
116
  │ ├── electron-install.js # Electron self-heal primitive (#53, #59).
117
+ │ ├── electron-layout.js # The on-disk LAYOUT of an installed `electron` package: where the executable
114
118
  │ ├── 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
119
+ │ ├── electron-native-plan.js # THE MECHANICS OF A RESCUE: write the verified buffer down, walk the platform's
120
+ │ ├── electron-native-rescue.js # THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor
121
+ │ ├── electron-provision.js # Electron CONTROLLED provision — the pinned download, and the fence that says
116
122
  │ ├── electron-quarantine.js # AV / antivirus quarantine detection for the electron self-heal (#53).
123
+ │ ├── electron-refuse.js # Electron artifact REFUSALS — the ways amicus declines to turn bytes into an
124
+ │ ├── electron-repair-cache.js # ATTEMPT 1 of the electron self-heal: turn a CACHED artifact into a `dist/`,
125
+ │ ├── electron-rescue-notice.js # THE TWO NOTICES the native-extractor rescue speaks — the offer a parse failure
117
126
  │ ├── 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.
127
+ │ ├── electron-trust.js # Electron artifact TRUST core — the digest anchor and the gate. (The third
119
128
  │ ├── fallback-chains.js
120
129
  │ ├── fanout-budget.js
121
130
  │ ├── fanout-leg-fallback.js
@@ -155,7 +164,12 @@ src/
155
164
  │ ├── unzip.js # Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24).
156
165
  │ ├── wave-progress.js
157
166
  │ ├── workspace-auto-open.js # Workspace Auto-Open Decision Helper
158
- └── workspace-window.js # Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
167
+ ├── workspace-window.js # Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
168
+ │ ├── zip-entry-write.js # ONE ENTRY of an in-memory archive, and the classified failures every caller
169
+ │ ├── zip-from-buffer.js # Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED.
170
+ │ ├── zip-local-name-scan.js # THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers.
171
+ │ ├── zip-name-scan.js # WHAT NAMES DOES THIS ARCHIVE DECLARE? A read-only walk of the central
172
+ │ └── zip-stall-bound.js # WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work.
159
173
  ├── template/
160
174
  │ ├── apply.js
161
175
  │ ├── render.js
@@ -405,6 +419,7 @@ scripts/
405
419
  ├── integration-test.sh
406
420
  ├── mark-test-passed.js # Writes the current git HEAD SHA to .test-passed for the pre-push SHA cache
407
421
  ├── postinstall.js # Post-install script for amicus
422
+ ├── probe-darwin-extract.js
408
423
  ├── probe-max-tokens.js # Wire probe for issue #218: what max_tokens / reasoning / thinking does the
409
424
  ├── run-integration-keyless.js
410
425
  ├── setup-hooks.js # Configure git to run the version-controlled hooks in .husky/.
@@ -550,14 +565,23 @@ evals/
550
565
  | `sidecar/continue.js` | Sidecar Continue Operations - Handles continuing from previous sessions | `loadPreviousSession()`, `buildContinuationContext()`, `createContinueSessionMetadata()`, `continueSidecar()` |
551
566
  | `sidecar/conversation-mirror.js` | | `createMirrorState()`, `mirrorMessages()`, `logMessage()`, `mirrorUsageOnly()`, `allAssistantUsagePresent()` |
552
567
  | `sidecar/crash-handler.js` | Crash Handler - Updates metadata to 'error' on uncaught exceptions | `installCrashHandler()` |
553
- | `sidecar/electron-cache.js` | Electron download-cache root resolution (#53 helper). | `resolveCacheRoots()`, `defaultCacheRoot()` |
568
+ | `sidecar/electron-cache.js` | Electron download-cache root resolution (#53 helper). | `resolveCacheRoots()`, `defaultCacheRoot()`, `cachedZip()` |
569
+ | `sidecar/electron-custody.js` | CUSTODY of the Electron artifact: one open, one read, one Buffer. | `readArtifactBytes()`, `isSafeArtifactName()`, `MAX_ARTIFACT_BYTES()`, `READ_CHUNK()` |
554
570
  | `sidecar/electron-ensure.js` | ensureElectron() — lazy first-GUI provisioning (#55). | `ensureElectron()`, `_resetEnsureElectron()` |
571
+ | `sidecar/electron-env-scrub.js` | The ENV SCRUB — which environment names a hostile REPOSITORY can plant. | `isRepoPlantedName()`, `withScrubbedRepoEnv()`, `REPO_ENV_PREFIXES()` |
572
+ | `sidecar/electron-exe-rel.js` | WHICH exe a package resolves through, and whether a `dist/` HOLDS one. | `platformExe()`, `writePathTxt()`, `heldExeRel()`, `distHeldExe()` |
555
573
  | `sidecar/electron-install.js` | Electron self-heal primitive (#53, #59). | `resolveElectronBinary()`, `isElectronUsable()`, `cachedZip()`, `repairElectron()`, `platformExe()` |
574
+ | `sidecar/electron-layout.js` | The on-disk LAYOUT of an installed `electron` package: where the executable | `platformExe()`, `writePathTxt()`, `promoteDist()`, `extractBytesToDist()`, `sweepPromoteLitter()` |
556
575
  | `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()` |
576
+ | `sidecar/electron-native-plan.js` | THE MECHANICS OF A RESCUE: write the verified buffer down, walk the platform's | `nativeRescue()`, `RESCUE_ZIP()`, `INCOMING_PREFIX()` |
577
+ | `sidecar/electron-native-rescue.js` | THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor | `withNativeRescue()`, `isRescuableFailure()`, `RESCUE_TRIGGER()`, `RESCUE_ZIP()`, `INCOMING_PREFIX()` |
578
+ | `sidecar/electron-provision.js` | Electron CONTROLLED provision — the pinned download, and the fence that says | `cacheRootFor()`, `controlledProvision()`, `mayDeleteRejectedZip()` |
558
579
  | `sidecar/electron-quarantine.js` | AV / antivirus quarantine detection for the electron self-heal (#53). | `avHint()`, `quarantineReason()`, `verifyExtractOutcome()` |
580
+ | `sidecar/electron-refuse.js` | Electron artifact REFUSALS — the ways amicus declines to turn bytes into an | `isUnsafeArchive()`, `refuseUnsafeArchive()`, `rejectCachedZip()`, `rejectDownloadedZip()`, `refuseUnreadableArtifact()` |
581
+ | `sidecar/electron-repair-cache.js` | ATTEMPT 1 of the electron self-heal: turn a CACHED artifact into a `dist/`, | `repairFromCache()` |
582
+ | `sidecar/electron-rescue-notice.js` | THE TWO NOTICES the native-extractor rescue speaks — the offer a parse failure | `offerNativeRescue()`, `announceNativeRescue()` |
559
583
  | `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()` |
584
+ | `sidecar/electron-trust.js` | Electron artifact TRUST core — the digest anchor and the gate. (The third | `electronTrustPolicy()`, `resolveAnchor()`, `expectedDigest()`, `verifyArtifactBytes()`, `sha256Bytes()` |
561
585
  | `sidecar/fallback-chains.js` | | `resolveFallbackConfig()`, `deriveChain()`, `vendorOf()`, `DEFAULT_MAX_SUBSTITUTIONS()` |
562
586
  | `sidecar/fanout-budget.js` | | `preflightBudget()` |
563
587
  | `sidecar/fanout-leg-fallback.js` | | `runLegWithFallback()`, `recordAttemptSpend()`, `sumAttemptUsage()` |
@@ -594,10 +618,15 @@ evals/
594
618
  | `sidecar/start-metadata.js` | | `createSessionMetadata()` |
595
619
  | `sidecar/start.js` | Sidecar Start Operations - Handles starting new sidecar sessions | `generateTaskId()`, `createSessionMetadata()`, `buildMcpConfig()`, `checkElectronAvailable()`, `runInteractive()` |
596
620
  | `sidecar/tool-part.js` | | `TERMINAL_TOOL_STATUSES()`, `LIVE_TOOL_STATUSES()`, `isToolPart()`, `toolPartName()`, `toolPartInput()` |
597
- | `sidecar/unzip.js` | Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24). | `robustExtract()`, `nativeUnzipPlan()`, `IDLE_MS()`, `MAX_MS()` |
621
+ | `sidecar/unzip.js` | Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24). | `robustExtract()`, `nativeUnzipPlan()`, `IDLE_MS()`, `MAX_MS()`, `UNSAFE_PATTERNS()` |
598
622
  | `sidecar/wave-progress.js` | | `formatWaveProgress()`, `readLegState()`, `createWaveHeartbeat()`, `WAVE_HEARTBEAT_INTERVAL()` |
599
623
  | `sidecar/workspace-auto-open.js` | Workspace Auto-Open Decision Helper | `shouldAutoOpenWorkspace()` |
600
624
  | `sidecar/workspace-window.js` | Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern: | `launchWorkspaceWindow()`, `launchWorkspaceWindowDetached()` |
625
+ | `sidecar/zip-entry-write.js` | ONE ENTRY of an in-memory archive, and the classified failures every caller | `failure()`, `badArchive()`, `badDestination()`, `outOfBound()`, `extractorUnavailable()` |
626
+ | `sidecar/zip-from-buffer.js` | Extract an archive that is ALREADY IN MEMORY and ALREADY HASHED. | `extractZipBuffer()` |
627
+ | `sidecar/zip-local-name-scan.js` | THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers. | `scanLocalNames()`, `declaredCentralOffset()` |
628
+ | `sidecar/zip-name-scan.js` | WHAT NAMES DOES THIS ARCHIVE DECLARE? A read-only walk of the central | `scanEntryNames()`, `nameRefusal()`, `SCAN_MS()`, `MAX_ENTRIES()` |
629
+ | `sidecar/zip-stall-bound.js` | WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work. | `IDLE_MS()`, `MAX_MS()`, `UNWIND_MS()`, `stalled()`, `awaitUnwind()` |
601
630
  | `template/apply.js` | | `applyTemplate()`, `ARTIFACT_CAP_BYTES()` |
602
631
  | `template/render.js` | | `renderTemplate()`, `KNOWN_VARIABLES()` |
603
632
  | `template/store.js` | | `templatesDir()`, `resolveTemplate()`, `listTemplates()`, `BUILTIN_TEMPLATES()` |
@@ -343,17 +343,22 @@ 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
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, **and** arm the native-extractor rescue for an archive amicus cannot read (below). 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 two cases only: you deliberately run a **rebuilt** Electron whose bytes legitimately differ, or amicus cannot read an archive at all and you have no other copy of it. 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 arms exactly the two
349
+ > things this page describes — the digest relaxation in the table above, and the
350
+ > native-extractor rescue in the blockquote below — and re-enables nothing beyond
351
+ > them. `npm_config_electron_mirror`, `npm_package_config_electron_*`
352
+ > and `npm_config_electron_use_remote_checksums` are stripped from the
353
+ > environment while amicus works out **where the artifact comes from**, whether
354
+ > this variable is set or not those are the names a *repository* can plant
355
+ > through its own `.npmrc` or `package.json`, which is why this variable is a
356
+ > plain environment variable read
357
+ > under that exact bare spelling only: a repo-planted
358
+ > `npm_config_amicus_allow_unverified_electron` reads back as unset. (`platform`
359
+ > and `arch` are no longer scrubbed anywhere, because there is no longer a child
360
+ > installer to hand an environment to: amicus's downloader takes them as
361
+ > arguments, so no environment name can choose which artifact is fetched.) It also does not change where the download comes from — a real
357
362
  > `ELECTRON_MIRROR` environment variable is honoured exactly as before, set or
358
363
  > unset. What it *does* relax, deliberately and on both routes, is the digest:
359
364
  > with it set, a cached artifact that contradicts Electron's published sha256 is
@@ -361,6 +366,109 @@ These variables control the polling loop that drives headless sessions. The defa
361
366
  > (`@electron/get` then trusts the `SHASUMS256.txt` served alongside the artifact).
362
367
  > Leave it unset and the published digest is enforced on both routes.
363
368
 
369
+ > **The second thing it arms: the native-extractor rescue, and the window that
370
+ > opens.** Amicus extracts the Electron archive **in memory**, from the buffer it
371
+ > hashed — nothing is written to a path and handed to anything else, which is what
372
+ > makes "amicus only ever writes bytes it hashed" true. The cost is that an
373
+ > archive its own extractor cannot read has nowhere else to go: normally that is a
374
+ > failed repair and a re-download, and on an air-gapped machine there is no
375
+ > re-download, so it is the end of the road. With this variable set, that one
376
+ > failure gets a rescue: amicus writes the bytes it hashed into a private
377
+ > directory inside the Electron package and hands **that path** to a native
378
+ > extractor (`tar` / `Expand-Archive` on Windows, `ditto` / `unzip` on macOS,
379
+ > `unzip` / `tar` on Linux). **Between amicus writing that file and the child
380
+ > process opening it, anything running as your user can replace it, and whatever
381
+ > the child extracts is promoted into `dist/` without being hashed again.** That
382
+ > is not a safe operation; it is the trade this variable buys, and amicus prints
383
+ > the whole of it on stderr before it spawns anything. A rescued install is always
384
+ > reported `unverified`, even when the artifact's own sha256 matched, because what
385
+ > reached `dist/` is no longer what amicus hashed.
386
+ >
387
+ > The rescue is deliberately narrow. It fires **only** when amicus's extractor
388
+ > positively identified the archive as unreadable — the same verdict that lets it
389
+ > discard a corrupt cached artifact, which is why that discard now waits: with
390
+ > this variable unset, amicus prints the offer of this rescue and **keeps** the
391
+ > cached archive, and only discards it once the rescue has run and every native
392
+ > extractor has failed on it too. A **path-traversal refusal** (`REFUSED
393
+ > (unsafe archive)`) is terminal and this variable does not apply to it, a
394
+ > **stall** is not a rescue trigger (the timeout exists to stop work, not to hand
395
+ > it to a child process), a **digest mismatch** is not one either (the bytes are
396
+ > known wrong, so there is nothing to rescue — even though this same variable let
397
+ > them reach the extractor), and neither is a destination failure such as a full
398
+ > disk. With the variable unset, an unreadable archive fails with a message that
399
+ > names this variable and says what it would do, so you do not have to read the
400
+ > source to find it.
401
+ >
402
+ > **What "path-traversal refusals are excluded" does and does not buy you.** That
403
+ > exclusion keys on the refusal amicus's extractor *formed*, and an archive can
404
+ > stop it forming one: yauzl checks an entry's size before its name, so one bad
405
+ > entry early in the archive ends the walk before any later name is looked at.
406
+ > Measured — the same three-entry archive, one flag bit apart — that moves it out
407
+ > of the terminal class and into the rescuable one. Amicus therefore also **reads
408
+ > the entry names the archive declares and refuses any that yauzl would have
409
+ > refused**, before it hands anything to a native extractor — and it reads them in
410
+ > **both of the tables a zip declares them in**, the central directory and the
411
+ > local file headers. That second walk is not belt-and-braces. An archive can
412
+ > blind the central directory while leaving every local header whole — a
413
+ > truncation does it by accident, and four one-field edits to a complete
414
+ > end-of-central-directory record do it on purpose — and measured, seven such
415
+ > archives carrying a `../../../` entry reached a native extractor before this
416
+ > was added, two of them running to completion. The tables can also **disagree**,
417
+ > and the tools do not agree on which one to believe: measured, `tar.exe` wrote
418
+ > the name from the LOCAL header and `Expand-Archive` the one from the CENTRAL
419
+ > directory. A refusal in either table refuses the archive.
420
+ >
421
+ > Residuals survive, and none is engineered away:
422
+ >
423
+ > - An archive that defeats **both** walks still reaches the native extractor.
424
+ > That is rarer than it was — a truncated zip, the commonest thing this rescue
425
+ > exists for, still yields all of its local names — but it is not impossible,
426
+ > and the notice printed before the spawn now tells you **which** names were
427
+ > checked rather than letting you assume they all were.
428
+ > - A **symlink whose target escapes** the extraction root is a payload, not a
429
+ > name, so no name scan can see it. Amicus's own in-memory extractor refuses
430
+ > those; a native extractor is not asked to.
431
+ > - The name walk trusts a local header's declared size to find the next one, and
432
+ > a wrong size is a property of exactly the corrupt archives this rescue is for.
433
+ > A desynchronised walk can read a "name" out of payload bytes. It fails toward
434
+ > **refusing**, so it can cost a rescue and never grant one.
435
+ >
436
+ > When a name check cannot see it, the remaining check is the extractor's own, and
437
+ > those are measured on every CI run rather than asserted here
438
+ > (`tests/sidecar/native-extractor-containment.test.js`, 12 escape shapes per
439
+ > strategy). At the time of writing: `tar.exe` refuses `..` (`Path contains '..'`,
440
+ > exit 1) and strips absolute and drive-letter names into the destination;
441
+ > `Expand-Archive` refuses `..` (`Can not process invalid archive entry '…'`) and
442
+ > errors out on drive-letter and `\\?\` shapes with an empty destination; Info-ZIP
443
+ > `unzip` contains by stripping the escaping components and writing the entry
444
+ > inside the destination under a mangled name (its own `-hh` text documents this
445
+ > as the default, with `-:` as the opt-out, which amicus does not pass); GNU `tar`
446
+ > cannot read a zip at all. **`ditto`, the first macOS strategy, is the one that
447
+ > remains unmeasured.**
448
+ >
449
+ > Everything amicus writes for a rescue goes under a private incoming directory
450
+ > that is deleted unconditionally afterwards, and the child is run with that
451
+ > directory as its working directory, so a relative write lands where the cleanup
452
+ > reaches. What amicus cannot clean up is a write a native tool makes to an
453
+ > absolute path outside that tree. This is the concrete shape of "not a safe
454
+ > operation": if the archive came from somewhere you do not trust, do not set this
455
+ > variable — get another copy.
456
+ >
457
+ > **The one thing the strip does not cover, stated precisely** (measured against
458
+ > `@electron/get` 5.0.0, and re-measured by
459
+ > `tests/electron-env-scrub-get5-contract.test.js` on every test run). Every read
460
+ > that decides the artifact's URL happens while those names are stripped — with a
461
+ > mirror planted, the download still goes to the official
462
+ > `github.com/electron/electron/releases/download/…`. But when **no digest is
463
+ > pinned** — either this variable is set, or your Electron package ships no
464
+ > `checksums.json` entry — `@electron/get` fetches a `SHASUMS256.txt` of its own,
465
+ > *after* the names have been restored, and a planted mirror is read again for
466
+ > that one fetch. It cannot change which bytes you get, because the artifact's
467
+ > URL was already settled: it can only serve a checksum file that disagrees with
468
+ > the official artifact, which makes the download **fail**. Unset the planted
469
+ > names (or the variable) if an unpinned download fails checksum validation on a
470
+ > machine whose `.npmrc` names an Electron mirror.
471
+
364
472
  ---
365
473
 
366
474
  ## Process Lifecycle