Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/system-update-status-and-resilience-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,132 @@ Fixture coverage exercises mismatch detection, false-positive rejection,
mixed upgrade batches, action formatting, notice promotion, and blocked status
classification in `tests/system-update/run.sh`.

## 22. 2026-08-02 npm install-script gate: risk-matched policy

**This section supersedes the install-script policy stated in §11 (item 4), §12,
§13, §15, §16.1, §17, and §20.** Those sections remain accurate as history. Where
they assert that every grant must carry an exact version, that invariant is
retired here and the reasoning is below.

### What the old gate measured

A walk of the live global tree (891 `package.json`) established the scale the
policy was operating at:

- **Zero** of the 12 top-level globals declares an install script in its own
manifest.
- **5 packages / 7 versions out of 891 — 0.79%** — carry one anywhere in the
tree: `@github/keytar`, `esbuild` (×2), `foundry-local-sdk`, `node-pty`,
`workerd` (×2).
- **Zero** of those scripts fetches from a non-registry host; `esbuild` and
`workerd` hard-code `registry.npmjs.org`, and keytar's is a local `fs.cpSync`.
- `@openai/codex` has **no install script anywhere in its tree**, so
`--strict-allow-scripts` on that lane has zero enforcement targets and can only
ever exit 0. Per the repo's own standard, a check that can only pass is not a
control and must not be rendered as one.

Meanwhile the exact-version grain generated the entire maintenance cost: over
2026-07 the gate named 7 dependency identities but **15 version tuples**, and
`workerd` alone moved 8 times in 33 days (§20 is the record of one such bump).
None of those edits was a review; each was a chore. The identity list caught the
only event that mattered — `sharp`, a genuinely new dependency Wrangler pulled in
on 2026-07-20.

### What replaces it

**Grain: dependency identity, not version.** `wrangler=esbuild,fsevents,workerd`
grants those identities at whatever version resolves, owner-scoped as before. A
*new* script-bearing identity still stops the install. Verified against npm
11.16.0 and 12.0.2: bare names and exact versions both match; semver ranges,
dist-tags, and globs are silently discarded by npm, so the parser now rejects
them by name rather than letting them fail open on a non-strict npm.
`SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN=true` restores the old grain.

Globs deserve their own note because they are the one refused form that looks
like it should work: the sibling knob `min-release-age-exclude` **is** minimatch,
while `allow-scripts` is not. Two matchers, opposite answers, one keystroke
apart.

**Cooldown, per lane.** `--min-release-age`: 2 days on stable globals, 0 on the
AI channels. Across seven documented npm hijacks the dwell-time distribution is
bimodal — five detected within 24 h, then nothing until 8–10 days and 72 days —
so 1, 2 and 7 days all catch the same five. Two days buys margin against the lag
between public detection and registry removal; longer buys no additional
incident. The AI lane is 0 because a cooldown there does not delay a channel: with
no version old enough, npm resolves the dist-tag to an older build and exits 0
silently. Verified on both majors — `@openai/codex@alpha` returned stable
`0.146.0`, `@github/copilot@prerelease` returned `1.0.77`.

`--min-release-age-exclude` is **never emitted**, and a test enforces that. It
exists only in npm ≥ 12; on npm 11.16 the unknown flag turns its value into a
positional package spec and the step dies with `E404`, exit 1.

**Artifact verification where the allowlist never applied.** The platform-binary
drop (`@openai/codex-darwin-arm64` and friends) is a plain tarball that no script
touches, so no allowlist could ever have caught it. `ai_cli_platform_dep_ok` was
already written but wired only into `--check`; it now also runs in update mode
whenever the parent version changed, which is the only event that causes the
drift. Command health cannot see this failure — the loader falls back and
`--version` answers from the JS wrapper.

### npm 12

npm 12.0.2 became `latest` on 2026-07-08 and **inverts the default**: npm 11 runs
an uncovered install script with a notice, npm 12 blocks it and still exits 0.
It also renames the log prefix `allow-scripts` → `install-scripts`. The counters
at `npm_pending_script_count` and `npm_script_review_packages` matched only the
npm-11 wording and would have silently returned zero, so a v12 host would have
installed globals without their native binaries and reported a clean green row.
Both now match either wording, `npm_scripts_blocked_at_exit_zero` distinguishes
the blocked-at-success case from a pending review, and both npm steps print an
`npm-major:` marker so the run record says which semantics applied.

The arrival lane is `mise upgrade --yes` against a floating `node = "24"`, with
no operator decision involved. There is runway: node 26.5.1 still bundles npm
11.17.0, so no Node line carries npm 12 yet. npm is deliberately **not** pinned or
upgraded — the mise shim at PATH position 3 shadows `~/.npm-global/bin`, corepack
is gone in Node 26, and a cross-runtime npm pin would be exactly the kind of
unowned config the OWN-6 pattern warns about.

On npm 12, `--strict-allow-scripts` changes job rather than losing it: the default
already blocks, so its remaining function is turning a silent skip into a loud
exit 1. It is the truthfulness control, not the security control. Dropping it on
npm 12 would buy silence, not permissiveness.

### Two defects fixed in passing

- **The AI lane could never reach `blocked` in production.**
`npm_script_review_is_sole_failure` rejected any log containing
`^ai-cli-channel-failed:`, but the channel step *always* prints that marker on a
strict block — it is the gate's own marker, not a second cause. The `blocked`
assertion passed only because its fixture omitted the line production emits.
§16.1 documented behavior the code did not have. A heal failure or retry
exhaustion is still treated as a genuine second cause.
- **`grep … | grep -qv` misclassified large logs.** Under `set -o pipefail` the
reader's short-circuit hands the writer SIGPIPE and the pipeline returns 141,
which `if` reads as "no second cause". Reproduced on this host at ≥800 matched
lines, where a genuine multi-cause failure rendered as `blocked`. Replaced with
a single `awk` pass.

### Reporting contract

Every tolerated state is now stated rather than rendered green:
`npm-script-identity-grant:` (a script ran at an unreviewed version),
`npm-global-resolved-drift:` (the cooldown held a package below the inventory
version), `ai-cli-cooldown-hazard:` (a cooldown is set on a dist-tag lane), and
`ai-cli-platform-drift:` (a channel advanced without its exact platform binary).
All four are printed markers promoted by `collect_notices`, because step
functions run in a subshell and cannot call `add_notice` directly.

### What this deliberately does not protect against

Runtime compromise. Across the same seven incidents, **43%** were delivered by
runtime or load-time code rather than an install script, including the most recent
(node-ipc, 2026-05-14), which explicitly bypassed lifecycle scripts. For a global
CLI invoked daily, "runtime" means every invocation. It also misses long-dwell
compromise — event-stream hid for ~72 days — and a 2-day cooldown delays this
host's own `npm audit fix` by 2 days.

## Related

- [`docs/brand/terminal-style.md`](./brand/terminal-style.md) — the four-tier brand spec (authority)
Expand Down
60 changes: 51 additions & 9 deletions home/dot_config/system-update/config.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,25 @@ SYSTEM_UPDATE_PIP_PACKAGES=(
)

# Reviewed install-script policy for ordinary global npm packages. Every
# updater-driven install uses npm's strict preflight. Keep grants owned by the
# top-level package and pin each script-bearing dependency to an exact version.
# updater-driven install uses npm's strict preflight, so an install script from
# a dependency IDENTITY that is not listed here stops the install.
#
# Two grains, both accepted by npm:
# "owner=dep" grant that identity at whatever version resolves
# "owner=dep@1.2.3" grant exactly that version
# Grants are always owner-scoped: listing esbuild under wrangler does not let
# any other global run esbuild's script. Semver ranges and dist-tags are
# rejected — npm silently discards them.
#
# The default grain is identity. Version pinning was reviewed and dropped: over
# 2026-07 the gate named 7 dependency identities but 15 version tuples, and
# workerd alone moved 8 times in 33 days. Every one of those pin bumps was a
# chore, not a review, while the identity list caught the only event that
# mattered (sharp, a brand-new dependency wrangler pulled in on 2026-07-20).
# Set SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN=true to force pins back on.
SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=(
# "owner-package=dependency@exact-version"
"wrangler=esbuild@0.28.1,fsevents@2.3.3,workerd@1.20260730.1"
# "owner-package=dependency" or "owner-package=dependency@exact-version"
"wrangler=esbuild,fsevents,workerd"
)

# AI CLI npm channels. The ordinary stable npm-global step skips these package
Expand All @@ -30,14 +44,42 @@ SYSTEM_UPDATE_AI_CLI_NPM_CHANNELS=(
"@github/copilot@prerelease"
)

# Reviewed install-script policy for global AI CLI channels. Format:
# "owner-package=dependency@exact-version,...". Installs run with npm's strict
# allow-scripts preflight, so any new or changed script dependency stops for
# review instead of running or silently landing a degraded package.
# Reviewed install-script policy for global AI CLI channels. Same grammar and
# same default grain as the stable lane above. These channels track nightly,
# alpha, and prerelease deliberately, so their dependency versions move
# constantly; the control worth keeping is on the dependency IDENTITY, which
# does not. A new script-bearing identity still stops the channel update.
SYSTEM_UPDATE_AI_CLI_NPM_ALLOW_SCRIPTS=(
"@google/gemini-cli=@github/keytar@7.10.6,node-pty@1.0.0"
"@google/gemini-cli=@github/keytar,node-pty"
)

# Require an exact version on every allow-scripts grant (both npm lanes).
# Default false. Tighten to true when a granted identity has published a
# release you have not read, or while investigating a supply-chain advisory
# that names one of the granted packages; expect a hand-edit per bump while on.
SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN=false

# Release-age cooldown in days, per lane. Only versions published more than N
# days ago are installed; 0 disables. Applies to the whole resolved tree, not
# just the named target.
#
# 2 days on the stable lane. Across seven documented npm hijacks the dwell-time
# distribution is bimodal — five were caught within 24h, then nothing until 8-10
# days and 72 days — so 1, 2 and 7 all catch the same five. 2 buys margin against
# the lag between public detection and registry removal; longer buys nothing.
# Raise to 10 only to cover the node-ipc-2022 band during an active campaign.
# Cost to know about: a cooldown also delays your own `npm audit fix`.
SYSTEM_UPDATE_NPM_GLOBAL_MIN_RELEASE_AGE=2

# 0 on the AI lane, deliberately. These channels track nightly/alpha/prerelease
# because that is the point of this machine. A cooldown here does NOT hold a
# channel back: with no version old enough, npm resolves the dist-tag to an
# older build and exits 0 silently. Verified on npm 11.16 and 12.0.2 —
# @openai/codex@alpha came back as stable 0.146.0 and @github/copilot@prerelease
# as 1.0.77. Setting this above 0 is a silent off-channel downgrade; the updater
# will emit a notice saying so rather than obey it quietly.
SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE=0

# Enable/disable steps or plugins by id (see: system-update --list)
#
# brew-casks controls cask UPGRADES only. Cask staleness is always reported by
Expand Down
Loading
Loading