diff --git a/docs/system-update-status-and-resilience-plan.md b/docs/system-update-status-and-resilience-plan.md index 60983f0..029f605 100644 --- a/docs/system-update-status-and-resilience-plan.md +++ b/docs/system-update-status-and-resilience-plan.md @@ -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) diff --git a/home/dot_config/system-update/config.tmpl b/home/dot_config/system-update/config.tmpl index a532d67..418483b 100644 --- a/home/dot_config/system-update/config.tmpl +++ b/home/dot_config/system-update/config.tmpl @@ -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 @@ -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 diff --git a/scripts/system-update.sh b/scripts/system-update.sh index c1c1ce1..00928f7 100755 --- a/scripts/system-update.sh +++ b/scripts/system-update.sh @@ -77,6 +77,27 @@ declare -a SYSTEM_UPDATE_GO_TOOLS=() declare -a SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=() declare -a SYSTEM_UPDATE_AI_CLI_NPM_CHANNELS=() declare -a SYSTEM_UPDATE_AI_CLI_NPM_ALLOW_SCRIPTS=() +# Tighten the allow-scripts grain: reject identity-only entries and require an +# exact version on every grant in both npm lanes. Default false — see +# docs/system-update-status-and-resilience-plan.md §22. +SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN="${SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN:-false}" + +# Release-age cooldown, in days, applied per lane via npm's --min-release-age. +# Only versions published more than N days ago are installed; 0 disables. +# +# The two lanes get different values because they carry different risk. Across +# seven documented npm hijacks, 1, 2 and 7 days all catch the same five — the +# dwell-time distribution is bimodal with an empty band between roughly one day +# and ten. 2 buys margin against the lag between public detection and registry +# removal, and nothing longer buys another incident. +# +# The AI lane defaults to 0 deliberately. 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 without a word. Verified on 11.16 and 12.0.2 — +# `@openai/codex@alpha` came back as stable 0.146.0, `@github/copilot@prerelease` +# as 1.0.77. A nonzero value here is a silent off-channel downgrade, not caution. +SYSTEM_UPDATE_NPM_GLOBAL_MIN_RELEASE_AGE="${SYSTEM_UPDATE_NPM_GLOBAL_MIN_RELEASE_AGE:-2}" +SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE="${SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE:-0}" # Tests source this file without building a step queue, so default to the normal # update-mode owner. Executed runs revise this after step selection. @@ -348,11 +369,21 @@ npm_script_review_is_sole_failure() { esac grep -q '^npm error --strict-allow-scripts:' "$file" 2>/dev/null || return 1 # A second, differently-caused npm error means the gate is not the sole cause. - if grep -E '^npm error code ' "$file" 2>/dev/null | grep -qv 'ESTRICTALLOWSCRIPTS'; then + # + # Single awk pass, not `grep | grep -q`: 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 silently rendered as `blocked`. + if awk '/^npm error code / && $0 !~ /ESTRICTALLOWSCRIPTS/ { found = 1 } + END { exit found ? 0 : 1 }' "$file" 2>/dev/null; then return 1 fi is_network_failure "$file" && return 1 - grep -qE '^ai-cli-channel-(failed|heal-failed):' "$file" 2>/dev/null && return 1 + # `ai-cli-channel-failed:` is the gate's OWN marker in this lane — the channel + # step always prints it when a strict block ends the install — so treating it + # as a second cause made the AI lane unable to reach `blocked` in production + # at all. A heal failure or a retry exhaustion IS a different cause. + grep -qE '^ai-cli-channel-(heal-failed|retryable):' "$file" 2>/dev/null && return 1 return 0 } @@ -590,17 +621,28 @@ npm_pending_script_count() { awk ' /^npm warn allow-scripts [0-9]+ packages?/ { total += $4 } /^npm error --strict-allow-scripts: [0-9]+ package/ { total += $4 } + /^npm warn install-scripts [0-9]+ packages? had install scripts blocked/ { total += $4 } END { print total + 0 } ' "$file" 2>/dev/null } +# npm 12 made uncovered install scripts blocked-by-default and still exits 0, so +# the package lands without the binary its script would have produced. That is +# the OPPOSITE of npm 11's non-strict warning, which ran the script. Without +# this predicate the count above is the same number for two contradictory +# outcomes, and a v12 host would render an unbuilt global as a clean green row. +npm_scripts_blocked_at_exit_zero() { + local file="$1" + grep -qE '^npm warn install-scripts [0-9]+ packages? had install scripts blocked' "$file" 2>/dev/null +} + # Names of the script-bearing packages npm listed under its allow-scripts # header, e.g. `npm error esbuild@0.28.1 (postinstall: node install.js)`. # Best-effort by design: the indented body is upstream wording, so the caller # must tolerate an empty result rather than depend on it. npm_script_review_packages() { local file="$1" - sed -nE 's/^npm (error|warn)([[:space:]]+allow-scripts)?[[:space:]]+([^[:space:]]+@[^[:space:]]+) \(.*/\3/p' "$file" 2>/dev/null \ + sed -nE 's/^npm (error|warn)([[:space:]]+allow-scripts|[[:space:]]+install-scripts)?[[:space:]]+([^[:space:]]+@[^[:space:]]+) \(.*/\3/p' "$file" 2>/dev/null \ | LC_ALL=C sort -u | head -8 | paste -sd',' - | sed 's/,/, /g' } @@ -695,7 +737,53 @@ brew_restart_action() { } add_npm_global_script_review_action() { - add_action_once "npm install scripts: review the named packages and scripts in the log; global installs cannot use 'npm approve-scripts'. If approved, add a version-pinned '--allow-scripts=' policy to the owning install command; never blanket-approve all scripts" + add_action_once "npm install scripts: review the named packages and scripts in the log; global installs cannot use 'npm approve-scripts'. If approved, add the dependency to the OWNING package's rule in ~/.config/system-update/config — '--allow-scripts=' grants that identity at any version, '--allow-scripts=' pins one version; never blanket-approve all scripts and never grant one owner's dependency to another" +} + +# A release-age cooldown that moved a stable global backwards. npm prints no +# warning for this and exits 0, so without a notice the row reads `updated` for +# a run that installed an older version than the inventory asked for. +add_npm_cooldown_drift_notice() { + local tmp="$1" drift count + drift="$(sed -n 's/^npm-global-resolved-drift: //p' "$tmp" 2>/dev/null | LC_ALL=C sort -u)" + [[ -n "$drift" ]] || return 0 + count="$(printf '%s\n' "$drift" | wc -l | tr -d ' ')" + drift="$(printf '%s\n' "$drift" | paste -sd';' - | sed 's/;/; /g')" + add_notice "npm globals: the release-age cooldown held ${count} package(s) below the version the inventory reported (${drift}); this is the cooldown working, not a failed update — lower SYSTEM_UPDATE_NPM_GLOBAL_MIN_RELEASE_AGE to take the newer build" +} + +# The AI lane ran with a cooldown set. This is never merely slower for a +# dist-tag: npm silently resolves the tag to an older build, so a channel can +# leave its track without a single warning line from npm. +add_ai_cli_cooldown_hazard_notice() { + local tmp="$1" days + days="$(sed -n 's/^ai-cli-cooldown-hazard: //p' "$tmp" 2>/dev/null | head -1)" + [[ -n "$days" ]] || return 0 + add_notice "AI CLI channels: SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE is ${days}; a cooldown on a dist-tag lane does not delay the channel, it silently resolves the tag to an older build (alpha/nightly/prerelease can come back as stable) — set it to 0 unless you intend that" +} + +# The parent advanced but its platform binary did not follow. Command health +# cannot see this: the loader falls back and `--version` still answers. +add_ai_cli_platform_drift_notice() { + local tmp="$1" drifted count + drifted="$(sed -n 's/^ai-cli-platform-drift: //p' "$tmp" 2>/dev/null | LC_ALL=C sort -u)" + [[ -n "$drifted" ]] || return 0 + count="$(printf '%s\n' "$drifted" | wc -l | tr -d ' ')" + drifted="$(printf '%s\n' "$drifted" | paste -sd';' - | sed 's/;/; /g')" + add_notice "AI CLI channels: ${count} channel(s) advanced without their exact platform binary (${drifted}); a targeted repair was attempted — verify with 'system-update --check --only ai-cli-channels'" +} + +# One notice per run naming every owner whose install scripts ran under an +# identity-scoped (unpinned) grant. This is the design's tolerated state, so it +# is stated rather than rendered as an unqualified success. It is not a warning: +# the run did exactly what the operator's policy authorised. +add_npm_script_identity_notice() { + local tmp="$1" label="$2" grants count + grants="$(sed -n 's/^npm-script-identity-grant: //p' "$tmp" 2>/dev/null | LC_ALL=C sort -u)" + [[ -n "$grants" ]] || return 0 + count="$(printf '%s\n' "$grants" | wc -l | tr -d ' ')" + grants="$(printf '%s\n' "$grants" | paste -sd';' - | sed 's/;/; /g')" + add_notice "${label}: ${count} package(s) ran install scripts under an identity-scoped grant, so the script's version was not reviewed this run (${grants}); a dependency identity that is not already granted still stops the install" } # Recovery for a bundle built for the other CPU architecture. @@ -771,7 +859,7 @@ is_actionable_step_warning() { is_homebrew_degraded_warning "$file" ;; "npm globals" | "AI CLI channels") - grep -qiE '^npm warn (deprecated|allow-scripts)' "$file" 2>/dev/null + grep -qiE '^npm warn (deprecated|allow-scripts|install-scripts)' "$file" 2>/dev/null ;; "mise runtimes") mise_runtime_has_actionable_warning "$file" @@ -1579,10 +1667,18 @@ collect_notices() { if [[ "$dep_count" -gt 0 ]]; then add_notice "npm: ${dep_count} deprecation warnings (see log, or rerun with --debug)" fi + add_npm_script_identity_notice "$tmp" "npm globals" + add_npm_cooldown_drift_notice "$tmp" allow_scripts_count="$(npm_pending_script_count "$tmp")" if [[ "$allow_scripts_count" -gt 0 ]]; then - if [[ $rc -ne 0 ]] && grep -q '^npm error --strict-allow-scripts:' "$tmp" 2>/dev/null; then - local blocked_target script_pkgs failed_count + local blocked_target script_pkgs failed_count + if npm_scripts_blocked_at_exit_zero "$tmp"; then + # npm >= 12 skips uncovered scripts instead of running them and still + # reports success, so the global can land without the binary its + # script builds. That is a degraded install, not a pending review. + script_pkgs="$(npm_script_review_packages "$tmp")" + add_notice "npm globals: npm blocked ${allow_scripts_count} uncovered install script(s) and still reported success (${script_pkgs:-see log}); the affected global may be missing a native binary until each dependency identity is granted to its owner" + elif [[ $rc -ne 0 ]] && grep -q '^npm error --strict-allow-scripts:' "$tmp" 2>/dev/null; then # Attribute the gate to a target only when it is the sole failure. A # run can carry unrelated npm-global-failed: targets, and naming the # wrong one is worse than reporting a bare count. @@ -1593,7 +1689,7 @@ collect_notices() { blocked_target="${blocked_target:-update}" fi script_pkgs="$(npm_script_review_packages "$tmp")" - add_notice "npm globals: ${blocked_target} blocked because ${allow_scripts_count} package install script(s) require explicit review (${script_pkgs:-see log}); validate the package and script purpose before adding an exact owner-scoped allowance" + add_notice "npm globals: ${blocked_target} blocked because ${allow_scripts_count} package install script(s) require explicit review (${script_pkgs:-see log}); validate the package and script purpose before granting the dependency identity to this owner" else add_notice "npm globals: ${allow_scripts_count} package install script(s) are pending explicit review; do not approve them without validating the package and script purpose" fi @@ -1613,12 +1709,21 @@ collect_notices() { if [[ "$ai_dep_count" -gt 0 ]]; then add_notice "AI CLI channels: ${ai_dep_count} npm deprecation warnings (see log, or rerun with --debug)" fi + add_npm_script_identity_notice "$tmp" "AI CLI channels" + add_ai_cli_cooldown_hazard_notice "$tmp" + add_ai_cli_platform_drift_notice "$tmp" ai_allow_scripts_count="$(npm_pending_script_count "$tmp")" if [[ "$ai_allow_scripts_count" -gt 0 ]]; then - if [[ $rc -ne 0 ]] && grep -q '^npm error --strict-allow-scripts:' "$tmp" 2>/dev/null; then + local ai_script_pkgs + if npm_scripts_blocked_at_exit_zero "$tmp"; then + # npm >= 12: the channel installed but its uncovered scripts were + # skipped, so a native helper may be absent even though the health + # probe passed. Report the skip, not a pending review. + ai_script_pkgs="$(npm_script_review_packages "$tmp")" + add_notice "AI CLI channels: npm blocked ${ai_allow_scripts_count} uncovered install script(s) and still reported success (${ai_script_pkgs:-see log}); the channel may be missing a native helper until each dependency identity is granted to its owner" + elif [[ $rc -ne 0 ]] && grep -q '^npm error --strict-allow-scripts:' "$tmp" 2>/dev/null; then # The install never ran, so command health was never consulted; the # advisory wording below would assert an outcome that does not exist. - local ai_script_pkgs ai_script_pkgs="$(npm_script_review_packages "$tmp")" add_notice "AI CLI channels: channel update blocked because ${ai_allow_scripts_count} package install script(s) require explicit review (${ai_script_pkgs:-see log}); the channel remains at its previously installed version" else @@ -2322,11 +2427,13 @@ step_npm_globals() { fi local target package failures=0 + echo "npm-major: $(npm_major_version)" for target in "${targets[@]}"; do package="${target%@*}" echo "updating npm global: ${target}" if npm_global_npm_install "$package" "$target" 2>&1; then echo "npm-global-updated: ${target}" + npm_global_report_resolved "$package" "$target" else echo "npm-global-failed: ${target}" failures=$(( failures + 1 )) @@ -2337,11 +2444,158 @@ step_npm_globals() { fi } -# Return a comma-separated, exact-version allowlist for one stable global npm -# package. Rules use "owner-package=dependency@version,...". Duplicate or -# malformed ownership records fail closed before npm mutates the global tree. +# --- release-age cooldown ------------------------------------------------------ + +# Compare what the inventory asked for against what actually landed. +# +# A cooldown resolves BACKWARD when the newest version sits inside the window: +# npm installs an older version, prints no warning, and exits 0. Without this +# the row reads `updated wrangler@4.118.0` for a run that installed 4.117.0. +# The requested version comes from the `npm outdated` inventory the step already +# holds, so this costs no extra registry round-trip. +npm_global_report_resolved() { + local package="$1" target="$2" want got + want="${target##*@}" + # Guard the unversioned and scoped-without-version shapes: `@scope/pkg` would + # otherwise yield `scope/pkg` and read as a drift against every install. + [[ "$want" =~ ^[0-9] ]] || return 0 + got="$(npm_installed_global_version "$package")" || return 0 + [[ -n "$got" ]] || return 0 + [[ "$got" == "$want" ]] && return 0 + echo "npm-global-resolved-drift: ${package} requested ${want} installed ${got}" +} + +# Normalized cooldown for one lane. Prints nothing when the knob is absent, +# zero, or not a plain non-negative integer — a malformed value must not become +# a silent `--min-release-age 0`-shaped argument, and must not fail the run. +npm_release_age_days() { + local raw="${1:-}" + [[ "$raw" =~ ^[0-9]+$ ]] || return 0 + [[ "$raw" -gt 0 ]] || return 0 + printf '%s' "$raw" +} + +# Emit the cooldown flag pair for a lane, or nothing. +# +# `--min-release-age-exclude` is deliberately never emitted. It exists only in +# npm >= 12, and on npm 11.16 the unknown flag makes its value a positional +# package spec: the run dies with `E404 GET /@github%2f*`, exit 1. A per-lane +# `--min-release-age` needs no exclude list, works unchanged on both majors, and +# cannot hard-fail the step on the npm this host actually runs. +npm_release_age_args() { + local days + days="$(npm_release_age_days "${1:-}")" + [[ -n "$days" ]] || return 0 + printf '%s\n%s\n' "--min-release-age" "$days" +} + +# Major version of the npm actually on PATH, or empty if it cannot be read. +# +# Printed as a marker by both npm steps because npm 12 inverted the +# install-script default: npm 11 RUNS an uncovered script with a notice, npm 12 +# BLOCKS it and still exits 0. The run record has to say which semantics applied +# or a degraded install is indistinguishable from a clean one after the fact. +npm_major_version() { + local ver + ver="$(npm --version 2>/dev/null)" || return 0 + [[ "$ver" =~ ^([0-9]+)\. ]] || return 0 + printf '%s' "${BASH_REMATCH[1]}" +} + +# --- allow-scripts entry grain ----------------------------------------------- +# +# npm's --allow-scripts flag accepts exactly two grains, both verified against +# the installed npm 11.16.0: +# +# identity `esbuild`, `@github/keytar` covers whatever version resolves +# pinned `esbuild@0.28.1` covers that one version +# +# It accepts NEITHER semver ranges nor dist-tags: it drops them with a +# `npm warn allow-scripts CLI flag: ignoring "…"` and then, under the strict +# preflight, blocks the install for a dependency the operator believed was +# allowed. Reject those here so the error names the rule instead of the +# dependency, and so a typo can never fail open on a non-strict npm. +# +# Deliberately narrower than npm in one place: npm also accepts an exact-version +# disjunction (`workerd@1.20260722.1 || 1.20260730.1`, verified on 11.16 and +# 12.0.2), which this rejects because it contains spaces. With identity as the +# default grain a disjunction buys nothing, and allowing spaces through the +# comma-split would widen the parser for a form nothing here needs. +# +# Prints the grain of one entry; returns 1 for anything npm would discard. +npm_allow_scripts_entry_kind() { + local entry="$1" version + if [[ -z "$entry" || "$entry" == *[[:space:]]* ]]; then + printf 'malformed\n' + return 1 + fi + # Globs are the trap this check exists for, and they are the one form that + # reads as if it must work. `min-release-age-exclude` IS minimatch, so + # `@github/*` looks like a natural sibling of a pattern that is valid two + # knobs away. It is not: allow-scripts matches an exact dependency identity, + # so npm accepts the entry, matches nothing with it, and blocks an install the + # operator believed was granted. Two different matchers, opposite answers. + case "$entry" in + *'*'* | *'?'* | *'['* | *']'*) + printf 'malformed\n' + return 1 + ;; + esac + # Drop a leading scope sigil so only a version separator can remain. + version="${entry#@}" + if [[ "$version" != *@* ]]; then + printf 'identity\n' + return 0 + fi + version="${version##*@}" + if [[ "$version" == [0-9]* ]]; then + printf 'pinned\n' + return 0 + fi + printf 'malformed\n' + return 1 +} + +# Validate every entry of one rule's comma list. Prints nothing on success; on +# failure prints the offending entry so the caller can name it. The list itself +# is returned to npm verbatim — this only vets it. +npm_allow_scripts_entries_check() { + local entries="$1" entry kind + local IFS=',' + for entry in $entries; do + entry="${entry#"${entry%%[![:space:]]*}"}" + entry="${entry%"${entry##*[![:space:]]}"}" + kind="$(npm_allow_scripts_entry_kind "$entry")" || { + printf '%s\n' "$entry" + return 1 + } + if [[ "$kind" == "identity" ]] && [[ "${SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN:-false}" == "true" ]]; then + printf '%s\n' "$entry" + return 1 + fi + done + return 0 +} + +# Comma-joined list of the identity-grain (unpinned) entries in one rule. Used +# to report what the run tolerated, not to decide anything. +npm_allow_scripts_identity_entries() { + local entries="$1" entry identities="" + local IFS=',' + for entry in $entries; do + entry="${entry#"${entry%%[![:space:]]*}"}" + entry="${entry%"${entry##*[![:space:]]}"}" + [[ "$(npm_allow_scripts_entry_kind "$entry")" == "identity" ]] || continue + identities="${identities:+${identities},}${entry}" + done + printf '%s' "$identities" +} + +# Return a comma-separated allowlist for one stable global npm package. Rules +# use "owner-package=dependency[@version],...". Duplicate or malformed ownership +# records fail closed before npm mutates the global tree. npm_global_allow_scripts_for_package() { - local package="$1" rule owner entries match="" + local package="$1" rule owner entries bad match="" for rule in "${SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS[@]}"; do if [[ "$rule" != *=* ]]; then echo "invalid npm global allow-scripts rule: ${rule} (expected owner=dependency@version,...)" >&2 @@ -2353,6 +2607,10 @@ npm_global_allow_scripts_for_package() { echo "invalid npm global allow-scripts rule: ${rule} (owner and entries are required)" >&2 return 2 fi + if ! bad="$(npm_allow_scripts_entries_check "$entries")"; then + echo "invalid npm global allow-scripts entry: ${bad} in rule ${rule} (use 'name' for any version or 'name@exact-version'; npm discards semver ranges and dist-tags)" >&2 + return 2 + fi if [[ "$owner" == "$package" ]]; then if [[ -n "$match" ]]; then echo "duplicate npm global allow-scripts owner: ${package}" >&2 @@ -2368,18 +2626,31 @@ npm_global_allow_scripts_for_package() { # channels. A new install script therefore requires an exact, owner-scoped # review record instead of landing silently or running by default. npm_global_npm_install() { - local package="$1" target="$2" allow_scripts + local package="$1" target="$2" allow_scripts identity local args=(install -g --strict-allow-scripts) + local -a age_args=() if ! allow_scripts="$(npm_global_allow_scripts_for_package "$package")"; then return 2 fi + mapfile -t age_args < <(npm_release_age_args "${SYSTEM_UPDATE_NPM_GLOBAL_MIN_RELEASE_AGE:-}") + if [[ ${#age_args[@]} -eq 2 ]]; then + args+=("${age_args[@]}") + echo "npm-global-cooldown: ${package} min-release-age=${age_args[1]}" + fi + identity="" if [[ -n "$allow_scripts" ]]; then args+=("--allow-scripts=${allow_scripts}") + identity="$(npm_allow_scripts_identity_entries "$allow_scripts")" echo "npm-global-install-policy: ${package} strict · allow-scripts=${allow_scripts}" else echo "npm-global-install-policy: ${package} strict · allow-scripts=none" fi - npm "${args[@]}" "$target" + npm "${args[@]}" "$target" || return 1 + # Printed only after npm actually ran the scripts. An identity grant is a + # deliberate tolerance, not a clean result: it means a script ran at a version + # nobody reviewed. Say so instead of rendering a bare green. + [[ -n "$identity" ]] && echo "npm-script-identity-grant: ${package}=${identity}" + return 0 } npm_global_channel_packages() { @@ -2607,7 +2878,7 @@ sweep_npm_staging() { # managed config without associative arrays. Duplicate or malformed ownership # records fail closed. ai_cli_allow_scripts_for_package() { - local package="$1" rule owner entries match="" + local package="$1" rule owner entries bad match="" for rule in "${SYSTEM_UPDATE_AI_CLI_NPM_ALLOW_SCRIPTS[@]}"; do if [[ "$rule" != *=* ]]; then echo "invalid AI CLI npm allow-scripts rule: ${rule} (expected owner=dependency@version,...)" >&2 @@ -2619,6 +2890,10 @@ ai_cli_allow_scripts_for_package() { echo "invalid AI CLI npm allow-scripts rule: ${rule} (owner and entries are required)" >&2 return 2 fi + if ! bad="$(npm_allow_scripts_entries_check "$entries")"; then + echo "invalid AI CLI npm allow-scripts entry: ${bad} in rule ${rule} (use 'name' for any version or 'name@exact-version'; npm discards semver ranges and dist-tags)" >&2 + return 2 + fi if [[ "$owner" == "$package" ]]; then if [[ -n "$match" ]]; then echo "duplicate AI CLI npm allow-scripts owner: ${package}" >&2 @@ -2634,18 +2909,31 @@ ai_cli_allow_scripts_for_package() { # script therefore stops before mutation unless its exact package version was # reviewed and recorded for the owning channel package. ai_cli_npm_install() { - local package="$1" version="$2" allow_scripts + local package="$1" version="$2" allow_scripts identity local args=(install -g --strict-allow-scripts) + local -a age_args=() if ! allow_scripts="$(ai_cli_allow_scripts_for_package "$package")"; then return 2 fi + # Obeyed if set, never silently. The default is 0 and the step emits a hazard + # notice whenever it is not, because on a dist-tag lane a cooldown does not + # delay the channel — it moves it. Honouring the knob keeps the operator in + # charge; the notice keeps the consequence from being a surprise. + mapfile -t age_args < <(npm_release_age_args "${SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE:-}") + if [[ ${#age_args[@]} -eq 2 ]]; then + args+=("${age_args[@]}") + fi + identity="" if [[ -n "$allow_scripts" ]]; then args+=("--allow-scripts=${allow_scripts}") + identity="$(npm_allow_scripts_identity_entries "$allow_scripts")" echo "ai-cli-install-policy: ${package} strict · allow-scripts=${allow_scripts}" else echo "ai-cli-install-policy: ${package} strict · allow-scripts=none" fi - npm "${args[@]}" "${package}@${version}" + npm "${args[@]}" "${package}@${version}" || return 1 + [[ -n "$identity" ]] && echo "npm-script-identity-grant: ${package}=${identity}" + return 0 } step_ai_cli_channels() { @@ -2663,6 +2951,18 @@ step_ai_cli_channels() { [[ "$retry_delay" =~ ^[0-9]+$ ]] || retry_delay=3 local spec package tag target failures=0 retryables=0 + if [[ "$MODE" != "check" ]]; then + echo "npm-major: $(npm_major_version)" + local ai_cooldown + ai_cooldown="$(npm_release_age_days "${SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE:-}")" + # Stated, not silently obeyed. A cooldown on a dist-tag lane does not hold a + # channel back — it resolves the tag to an older build and exits 0 without a + # word, which is how `@openai/codex@alpha` becomes stable 0.146.0. If the + # operator has set one anyway, the run record has to say so. + if [[ -n "$ai_cooldown" ]]; then + echo "ai-cli-cooldown-hazard: ${ai_cooldown}" + fi + fi for spec in "${specs[@]}"; do package="${spec%@*}" tag="${spec##*@}" @@ -2706,8 +3006,11 @@ step_ai_cli_channels() { echo "ai-cli-channel-platform-missing: ${spec}" fi else - local attempt install_tmp installed=false + local attempt install_tmp installed=false pre_version installed_version="" install_tmp="$(mktemp "${TMPDIR:-/tmp}/tmp.system-update.XXXXXX")" + # Read before the install so the platform-integrity check below can fire + # on the one event that causes the drift: the parent version moving. + pre_version="$(npm_installed_global_version "$package" 2>/dev/null || true)" echo "installing ${package}@${tag}" for ((attempt = 0; attempt <= max_retries; attempt++)); do if [[ "$attempt" -gt 0 ]]; then @@ -2720,8 +3023,7 @@ step_ai_cli_channels() { # above: `ai-cli-channel-ok:` is counted by collect_notices and # asserted by the harness, and a format change there would break both # for a purely informational field. - local installed_version - if installed_version="$(ai_cli_installed_version "$package")"; then + if installed_version="$(npm_installed_global_version "$package")"; then echo "ai-cli-channel-version: ${package} ${installed_version}" fi installed=true @@ -2749,6 +3051,18 @@ step_ai_cli_channels() { if ! ai_cli_heal_platform_dep "$package" "$tag"; then failures=$(( failures + 1 )) fi + elif [[ -n "$installed_version" && "$installed_version" != "$pre_version" ]]; then + # The exact-version platform check existed but was wired only into + # --check, so an update could advance the parent while leaving its native + # binary a release behind and still go green: the loader falls back and + # `--version` answers from the JS wrapper, so command health cannot see + # it. Run it on the path that actually causes the drift. + if ! ai_cli_platform_dep_ok "$package"; then + echo "ai-cli-platform-drift: ${spec}" + if ! ai_cli_heal_platform_dep "$package" "$tag"; then + failures=$(( failures + 1 )) + fi + fi fi rm -f "$install_tmp" fi @@ -2812,7 +3126,10 @@ ai_cli_platform_dep_version_ok() { # run logs carried no version for the three fastest-moving packages on the host # — codex, copilot and gemini appeared at no version, ever. Check mode already # emitted one via `npm view`; this closes the update-mode half. -ai_cli_installed_version() { +# Installed version of any global package, read from its own manifest. Nothing +# about this is AI-channel specific; the stable lane's post-install drift check +# reads the same field the same way, so both go through one implementation. +npm_installed_global_version() { local package="$1" have node || return 1 local prefix pkgdir @@ -2829,6 +3146,11 @@ ai_cli_installed_version() { ' "${pkgdir}/package.json" 2>/dev/null } +# Retained name: the AI-channel step and its tests call this. +ai_cli_installed_version() { + npm_installed_global_version "$1" +} + ai_cli_missing_platform_pkgs() { local package="$1" have node || return 0 diff --git a/tests/system-update/run.sh b/tests/system-update/run.sh index 2f9bad6..1191f10 100755 --- a/tests/system-update/run.sh +++ b/tests/system-update/run.sh @@ -240,6 +240,54 @@ npm error esbuild@0.28.1 (postinstall: node install.js) npm-global-failed: wrangler@4.113.0 EOF +# Production shape: step_ai_cli_channels ALWAYS prints ai-cli-channel-failed: +# when a strict block ends the install, so the fixture below without it could +# never occur in a real run. The lane must still reach `blocked`. +ai_strict_scripts_real_log="$TMP_ROOT/ai-strict-scripts-real.log" +cat >"$ai_strict_scripts_real_log" <<'EOF' +installing @openai/codex@alpha +ai-cli-install-policy: @openai/codex strict · allow-scripts=none +npm error code ESTRICTALLOWSCRIPTS +npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts: +npm error node-pty@1.0.0 (install: node-gyp rebuild) +ai-cli-channel-failed: @openai/codex@alpha +EOF + +# A second, differently-caused npm error buried under enough matched lines to +# make `grep | grep -q` take SIGPIPE and return 141 under pipefail. This must +# stay `fail`; a 141 read as "no second cause" would render it `blocked`. +npm_strict_plus_bulk_log="$TMP_ROOT/npm-strict-plus-bulk.log" +{ + for _ in $(seq 1 900); do echo "npm error code EEXIST"; done + echo "npm error code ESTRICTALLOWSCRIPTS" + echo "npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts:" + echo "npm-global-failed: wrangler@4.118.0" +} >"$npm_strict_plus_bulk_log" + +# npm >= 12 skips uncovered install scripts and still exits 0, so the global +# lands without the binary the script builds. Verbatim wording from npm 12.0.2. +npm_v12_blocked_log="$TMP_ROOT/npm-v12-blocked.log" +cat >"$npm_v12_blocked_log" <<'EOF' +npm-global-install-policy: wrangler strict · allow-scripts=none +added 36 packages in 738ms +npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts: +npm warn install-scripts esbuild@0.28.1 (postinstall: node install.js) +npm warn install-scripts fsevents@2.3.3 (install: node-gyp rebuild) +npm warn install-scripts workerd@1.20260730.1 (postinstall: node install.js) +npm-global-updated: wrangler@4.118.0 +EOF + +# A clean run under identity-grain grants. Nothing failed; the point is that the +# tolerated state is stated rather than rendered as an unqualified success. +npm_identity_grant_log="$TMP_ROOT/npm-identity-grant.log" +cat >"$npm_identity_grant_log" <<'EOF' +updating npm global: wrangler@4.118.0 +npm-global-install-policy: wrangler strict · allow-scripts=esbuild,fsevents,workerd +changed 36 packages in 4s +npm-script-identity-grant: wrangler=esbuild,fsevents,workerd +npm-global-updated: wrangler@4.118.0 +EOF + ai_strict_scripts_log="$TMP_ROOT/ai-strict-scripts.log" cat >"$ai_strict_scripts_log" <<'EOF' installing @openai/codex@alpha @@ -415,8 +463,14 @@ assert_status blocked "npm globals" 1 "$npm_wrangler_blocked_log" assert_status blocked "AI CLI channels" 1 "$ai_strict_scripts_log" # Any second, differently-caused failure keeps the step in the ordinary fail # lane, because that other failure may well be retryable. +assert_status blocked "AI CLI channels" 1 "$ai_strict_scripts_real_log" +# Any second, differently-caused failure keeps the step in the ordinary fail +# lane, because that other failure may well be retryable. assert_status fail "npm globals" 1 "$npm_two_failure_log" assert_status fail "npm globals" 1 "$npm_strict_plus_network_log" +assert_status fail "npm globals" 1 "$npm_strict_plus_bulk_log" +# npm >= 12 exits 0 while skipping the scripts, so the row must not be `ok`. +assert_status warn "npm globals" 0 "$npm_v12_blocked_log" assert_status ok "mise runtimes" 0 "$mise_deprecation_log" assert_status warn "mise runtimes" 0 "$mise_general_deprecation_log" assert_status warn "Cleanup" 0 "$cleanup_warn_log" @@ -567,6 +621,36 @@ else not_ok "AI-channel review gate asserted an outcome that never happened" fi +# npm >= 12 blocks the script and still reports success. "Pending review" would +# describe the opposite outcome (npm 11 non-strict RUNS the script), so the two +# must not share a notice. +NOTICES=() +ACTION_ITEMS=() +collect_notices "npm globals" "$npm_v12_blocked_log" 0 +if [[ ${#NOTICES[@]} -eq 1 \ + && ${#ACTION_ITEMS[@]} -eq 1 \ + && "${NOTICES[0]}" == *"blocked 3 uncovered install script(s) and still reported success"* \ + && "${NOTICES[0]}" == *"workerd@1.20260730.1"* \ + && "${NOTICES[0]}" != *"pending explicit review"* ]]; then + ok "npm 12 script blocking is reported as a degraded install, not a pending review" +else + not_ok "npm 12 script blocking was silent or described as a pending review" +fi + +# The tolerated state is named. An identity grant means a script ran at a +# version nobody read; a bare green row would not be truthful about that. +NOTICES=() +ACTION_ITEMS=() +collect_notices "npm globals" "$npm_identity_grant_log" 0 +if [[ ${#NOTICES[@]} -eq 1 \ + && ${#ACTION_ITEMS[@]} -eq 0 \ + && "${NOTICES[0]}" == *"identity-scoped grant"* \ + && "${NOTICES[0]}" == *"wrangler=esbuild,fsevents,workerd"* ]]; then + ok "an identity-scoped script grant is disclosed instead of rendering a bare success" +else + not_ok "an identity-scoped script grant was hidden or raised a spurious action" +fi + NOTICES=() ACTION_ITEMS=() collect_notices "mise runtimes" "$mise_deprecation_log" 0 @@ -1000,12 +1084,213 @@ fi if ( # shellcheck source=/dev/null source "$ROOT/home/dot_config/system-update/config.tmpl" - [[ "$(npm_global_allow_scripts_for_package "wrangler")" == "esbuild@0.28.1,fsevents@2.3.3,workerd@1.20260730.1" \ + [[ "$(npm_global_allow_scripts_for_package "wrangler")" == "esbuild,fsevents,workerd" \ && -z "$(npm_global_allow_scripts_for_package "vercel")" ]] ); then - ok "managed config grants only Wrangler's reviewed exact install scripts" + ok "managed config grants only Wrangler's reviewed script-bearing identities" +else + not_ok "managed Wrangler script policy is missing, over-broad, or leaked to another owner" +fi + +# Both grains npm actually accepts, and the two forms it silently discards. +# A discarded entry is worse than a rejected one: on a non-strict npm it fails +# open and the script runs anyway, so the parser must refuse it by name. +grain_ok=true +for good in "esbuild" "@github/keytar" "esbuild@0.28.1" "@github/keytar@7.10.6"; do + npm_allow_scripts_entry_kind "$good" >/dev/null || grain_ok=false +done +for bad in "esbuild@latest" "esbuild@^0.28.0" "esbuild@~0.28.0" "esbuild@*" "esbuild@" "esbuild@0.28.0 || 0.28.1" ""; do + npm_allow_scripts_entry_kind "$bad" >/dev/null && grain_ok=false +done +if $grain_ok \ + && [[ "$(npm_allow_scripts_entry_kind "esbuild")" == "identity" ]] \ + && [[ "$(npm_allow_scripts_entry_kind "@github/keytar@7.10.6")" == "pinned" ]]; then + ok "allow-scripts grain accepts identities and exact pins and rejects ranges and dist-tags" +else + not_ok "allow-scripts grain accepted a spec npm would discard, or rejected one it accepts" +fi + +# shellcheck disable=SC2034 # consumed by sourced npm_global_allow_scripts_for_package +SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=( + "wrangler=esbuild,fsevents,workerd" + "vercel=esbuild@0.27.0" +) +if [[ "$(npm_global_allow_scripts_for_package "wrangler")" == "esbuild,fsevents,workerd" \ + && "$(npm_global_allow_scripts_for_package "vercel")" == "esbuild@0.27.0" ]]; then + ok "identity and pinned grains coexist across owners without leaking" +else + not_ok "mixed-grain allow-scripts rules were rewritten or leaked across owners" +fi + +SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=("wrangler=esbuild@^0.28.0") +if ! npm_global_allow_scripts_for_package "wrangler" >/dev/null 2>&1; then + ok "a semver range in a grant fails the install closed instead of reaching npm" +else + not_ok "a semver range npm would discard was handed to npm anyway" +fi + +SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=("wrangler=esbuild,workerd") +SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN=true +require_pin_blocks=false +npm_global_allow_scripts_for_package "wrangler" >/dev/null 2>&1 || require_pin_blocks=true +# shellcheck disable=SC2034 # read by sourced npm_allow_scripts_entries_check +SYSTEM_UPDATE_NPM_ALLOW_SCRIPTS_REQUIRE_PIN=false +if $require_pin_blocks && [[ "$(npm_global_allow_scripts_for_package "wrangler")" == "esbuild,workerd" ]]; then + ok "REQUIRE_PIN=true rejects identity grants and false restores them" +else + not_ok "the REQUIRE_PIN tightening knob did not change the accepted grain" +fi + +# shellcheck disable=SC2329 # invoked indirectly by sourced npm_global_npm_install +npm() { + printf '%s\n' "$@" +} +identity_marker="$(npm_global_npm_install "wrangler" "wrangler@4.118.0")" +# shellcheck disable=SC2034 # read by sourced npm_global_allow_scripts_for_package +SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=("wrangler=esbuild@0.28.1") +pinned_marker="$(npm_global_npm_install "wrangler" "wrangler@4.118.0")" +unset -f npm +if [[ "$identity_marker" == *"npm-script-identity-grant: wrangler=esbuild,workerd"* \ + && "$pinned_marker" != *"npm-script-identity-grant:"* ]]; then + ok "the identity-grant marker is emitted only for grants that skipped version review" +else + not_ok "the identity-grant disclosure marker was missing or emitted for a pinned grant" +fi + +# Globs are the one rejected form that looks like it must work: the sibling knob +# min-release-age-exclude IS minimatch. allow-scripts is not, so npm takes the +# entry, matches nothing with it, and blocks an install believed to be granted. +glob_grain_ok=true +for globbed in "@github/*" "*" "esbuild*" "esbuild?" "es[bd]uild" "@github/*@1.0.0"; do + npm_allow_scripts_entry_kind "$globbed" >/dev/null && glob_grain_ok=false +done +if $glob_grain_ok; then + ok "allow-scripts grain rejects globs, which npm silently matches against nothing" +else + not_ok "a glob was accepted as a grant and would silently match no package at all" +fi + +# --- release-age cooldown ---------------------------------------------------- + +# A zero or malformed cooldown must vanish entirely rather than reach npm as a +# flag carrying a junk value. +cooldown_grain_ok=true +for bad_age in "" "0" "-1" "2.5" "two" " " "2 3"; do + [[ -z "$(npm_release_age_args "$bad_age")" ]] || cooldown_grain_ok=false +done +if $cooldown_grain_ok && [[ "$(npm_release_age_args 2)" == $'--min-release-age\n2' ]]; then + ok "a release-age cooldown is emitted only for a positive integer" +else + not_ok "a release-age cooldown was emitted for a malformed value, or malformed for a valid one" +fi + +# npm 11.16 does not merely ignore --min-release-age-exclude: the unknown flag +# makes its value a positional package spec and the install dies with E404, +# exit 1. No code path here may emit it, comments excepted. +if ! awk '/^[[:space:]]*#/ { next } + /--min-release-age-exclude/ { found = 1 } + END { exit found ? 0 : 1 }' "$ROOT/scripts/system-update.sh"; then + ok "the updater never emits --min-release-age-exclude, which hard-fails npm 11.16" +else + not_ok "the updater can emit --min-release-age-exclude, fatal on the npm this host runs" +fi + +# The lane split: a cooldown on the stable globals, none by default on the AI +# channels, where it silently resolves a dist-tag to an older build. Obeyed if +# the operator sets it anyway — the step discloses it rather than ignoring it. +# shellcheck disable=SC2034 # all four are read by the sourced install wrappers +SYSTEM_UPDATE_NPM_GLOBAL_ALLOW_SCRIPTS=("wrangler=esbuild") +# shellcheck disable=SC2034 +SYSTEM_UPDATE_AI_CLI_NPM_ALLOW_SCRIPTS=() +# shellcheck disable=SC2034 +SYSTEM_UPDATE_NPM_GLOBAL_MIN_RELEASE_AGE=2 +SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE=0 +# shellcheck disable=SC2329 # invoked indirectly by the sourced install wrappers +npm() { + printf '%s\n' "$@" +} +stable_age_args="$(npm_global_npm_install "wrangler" "wrangler@4.118.0")" +ai_age_default="$(ai_cli_npm_install "@openai/codex" "alpha")" +SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE=3 +ai_age_forced="$(ai_cli_npm_install "@openai/codex" "alpha")" +# shellcheck disable=SC2034 # read by the sourced install wrappers +SYSTEM_UPDATE_AI_CLI_MIN_RELEASE_AGE=0 +unset -f npm +if [[ "$stable_age_args" == *"--min-release-age"* \ + && "$stable_age_args" == *"npm-global-cooldown: wrangler min-release-age=2"* \ + && "$ai_age_default" != *"--min-release-age"* \ + && "$ai_age_forced" == *"--min-release-age"* ]]; then + ok "the cooldown covers stable globals, is off by default for AI channels, and is obeyed when set" +else + not_ok "the cooldown leaked into the AI lane by default, was ignored when set, or missed stable globals" +fi + +# shellcheck disable=SC2329 # invoked indirectly by the sourced helper +npm() { + printf '11.16.0\n' +} +npm_major_seen="$(npm_major_version)" +# shellcheck disable=SC2329 # invoked indirectly by the sourced helper +npm() { + printf 'not-a-version\n' +} +npm_major_junk="$(npm_major_version)" +unset -f npm +if [[ "$npm_major_seen" == "11" && -z "$npm_major_junk" ]]; then + ok "the npm major marker reads a real version and stays empty on unparseable output" +else + not_ok "the npm major marker misread the installed npm version" +fi + +cooldown_drift_log="$TMP_ROOT/npm-cooldown-drift.log" +cat >"$cooldown_drift_log" <<'EOF' +npm-major: 11 +npm-global-cooldown: wrangler min-release-age=2 +updating npm global: wrangler@4.118.0 +npm-global-updated: wrangler@4.118.0 +npm-global-resolved-drift: wrangler requested 4.118.0 installed 4.117.0 +EOF +NOTICES=() +ACTION_ITEMS=() +collect_notices "npm globals" "$cooldown_drift_log" 0 +if [[ ${#NOTICES[@]} -eq 1 && "${NOTICES[0]}" == *"wrangler requested 4.118.0 installed 4.117.0"* ]]; then + ok "a cooldown-induced downgrade is reported instead of rendering as a clean update" +else + not_ok "a cooldown moved a global backwards and the run still reported a clean update" +fi + +ai_cooldown_log="$TMP_ROOT/ai-cooldown-hazard.log" +cat >"$ai_cooldown_log" <<'EOF' +npm-major: 11 +ai-cli-cooldown-hazard: 3 +installing @openai/codex@alpha +ai-cli-channel-ok: @openai/codex@alpha +ai-cli-channel-version: @openai/codex 0.146.0 +EOF +NOTICES=() +ACTION_ITEMS=() +collect_notices "AI CLI channels" "$ai_cooldown_log" 0 +if [[ ${#NOTICES[@]} -eq 1 && "${NOTICES[0]}" == *"does not delay the channel"* ]]; then + ok "a cooldown set on the AI channel lane is disclosed as an off-channel hazard" +else + not_ok "a cooldown on the AI lane was obeyed without disclosing that it moves the channel" +fi + +ai_drift_log="$TMP_ROOT/ai-platform-drift.log" +cat >"$ai_drift_log" <<'EOF' +npm-major: 11 +installing @github/copilot@prerelease +ai-cli-channel-ok: @github/copilot@prerelease +ai-cli-channel-version: @github/copilot 1.0.78-2 +ai-cli-platform-drift: @github/copilot@prerelease +EOF +NOTICES=() +ACTION_ITEMS=() +collect_notices "AI CLI channels" "$ai_drift_log" 0 +if [[ ${#NOTICES[@]} -eq 1 && "${NOTICES[0]}" == *"advanced without their exact platform binary"* ]]; then + ok "a channel that advanced without its platform binary is reported, not rendered green" else - not_ok "managed Wrangler script policy is missing, unpinned, or leaked to another owner" + not_ok "a channel advanced without its platform binary and the row stayed silent" fi ai_platform_prefix="$TMP_ROOT/ai-platform-prefix"